From 8b373b11e3f808becdbf58284fbf3781aa9340d0 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 06:41:45 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=ED=88=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 2 +- .../controller/LiveConditionController.java | 35 + .../goldenchart/dto/LiveConditionRowDto.java | 29 + .../dto/LiveConditionStatusDto.java | 25 + .../service/LiveConditionStatusService.java | 313 +++++ .../service/LiveStrategySettingsService.java | 36 +- .../service/StrategyDslToTa4jAdapter.java | 33 + .../websocket/DynamicSubscriptionManager.java | 29 +- .../migration/V28__virtual_trading_menu.sql | 6 + frontend/src/App.tsx | 13 + frontend/src/components/MarketSearchPanel.tsx | 200 ++- frontend/src/components/TopMenuBar.tsx | 11 +- .../src/components/VirtualTradingPage.tsx | 336 +++++ .../components/layout/BuilderPageShell.tsx | 75 +- .../virtual/VirtualIndicatorCompareTable.tsx | 84 ++ .../virtual/VirtualLeftTargetPanel.tsx | 165 +++ .../components/virtual/VirtualLiveBadge.tsx | 27 + .../virtual/VirtualSignalEqualizer.tsx | 64 + .../virtual/VirtualSignalTrafficLight.tsx | 29 + .../virtual/VirtualStrategyChartPopup.tsx | 206 ++++ .../components/virtual/VirtualTargetCard.tsx | 101 ++ .../components/virtual/VirtualTargetGrid.tsx | 102 ++ .../src/hooks/useVirtualIndicatorSnapshots.ts | 213 ++++ .../src/hooks/useVirtualTargetLiveStatus.ts | 119 ++ .../src/styles/virtualTradingDashboard.css | 1077 +++++++++++++++++ frontend/src/utils/backendApi.ts | 53 + frontend/src/utils/permissions.ts | 5 +- frontend/src/utils/stompChartBroker.ts | 22 +- .../src/utils/strategyToChartIndicators.ts | 246 ++++ frontend/src/utils/virtualSignalMetrics.ts | 154 +++ .../src/utils/virtualStrategyConditions.ts | 100 ++ frontend/src/utils/virtualTradingStorage.ts | 86 ++ image/stock_status_ui.png | Bin 0 -> 1590533 bytes 33 files changed, 3897 insertions(+), 99 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/controller/LiveConditionController.java create mode 100644 backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java create mode 100644 backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java create mode 100644 backend/src/main/resources/db/migration/V28__virtual_trading_menu.sql create mode 100644 frontend/src/components/VirtualTradingPage.tsx create mode 100644 frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx create mode 100644 frontend/src/components/virtual/VirtualLeftTargetPanel.tsx create mode 100644 frontend/src/components/virtual/VirtualLiveBadge.tsx create mode 100644 frontend/src/components/virtual/VirtualSignalEqualizer.tsx create mode 100644 frontend/src/components/virtual/VirtualSignalTrafficLight.tsx create mode 100644 frontend/src/components/virtual/VirtualStrategyChartPopup.tsx create mode 100644 frontend/src/components/virtual/VirtualTargetCard.tsx create mode 100644 frontend/src/components/virtual/VirtualTargetGrid.tsx create mode 100644 frontend/src/hooks/useVirtualIndicatorSnapshots.ts create mode 100644 frontend/src/hooks/useVirtualTargetLiveStatus.ts create mode 100644 frontend/src/styles/virtualTradingDashboard.css create mode 100644 frontend/src/utils/strategyToChartIndicators.ts create mode 100644 frontend/src/utils/virtualSignalMetrics.ts create mode 100644 frontend/src/utils/virtualStrategyConditions.ts create mode 100644 frontend/src/utils/virtualTradingStorage.ts create mode 100644 image/stock_status_ui.png diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index 1008bb1..e4de05f 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "strategy", "strategy-editor", "backtest", "notifications", "settings", + "dashboard", "chart", "paper", "virtual", "strategy", "strategy-editor", "backtest", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin" ); diff --git a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java new file mode 100644 index 0000000..4557ffe --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java @@ -0,0 +1,35 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.LiveConditionStatusDto; +import com.goldenchart.service.LiveConditionStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 가상투자 — 실시간 조건 충족 현황 API. + * + * GET /api/strategy/live-conditions?market=KRW-BTC&strategyId=1 + */ +@RestController +@RequestMapping("/strategy/live-conditions") +@RequiredArgsConstructor +public class LiveConditionController { + + private final LiveConditionStatusService service; + + @GetMapping + public ResponseEntity get( + @RequestParam String market, + @RequestParam long strategyId, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + return ResponseEntity.ok(service.evaluate(parseUserId(userIdHeader), deviceId, market, strategyId)); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java new file mode 100644 index 0000000..7f80a59 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java @@ -0,0 +1,29 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LiveConditionRowDto { + + private String id; + private String indicatorType; + private String displayName; + private String conditionType; + private String conditionLabel; + private String thresholdLabel; + private Double currentValue; + private Double targetValue; + /** true=충족, false=미충족, null=평가 불가 */ + private Boolean satisfied; + private String timeframe; + /** buy | sell */ + private String side; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java new file mode 100644 index 0000000..e13124b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java @@ -0,0 +1,25 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LiveConditionStatusDto { + + private String market; + private Long strategyId; + private String timeframe; + /** 0~100 (충족 조건 비율) */ + private int matchRate; + private List rows; + private long updatedAt; +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java new file mode 100644 index 0000000..325057f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -0,0 +1,313 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.goldenchart.dto.LiveConditionRowDto; +import com.goldenchart.dto.LiveConditionStatusDto; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.websocket.DynamicSubscriptionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Rule; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 가상투자 — 종목×전략별 조건 충족 현황 (Ta4j Rule.isSatisfied). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class LiveConditionStatusService { + + private static final Map CONDITION_LABEL = Map.ofEntries( + Map.entry("GT", "초과(>)"), + Map.entry("LT", "미만(<)"), + Map.entry("GTE", "이상(≥)"), + Map.entry("LTE", "이하(≤)"), + Map.entry("EQ", "같음(=)"), + Map.entry("NEQ", "다름(≠)"), + Map.entry("CROSS_UP", "상향 돌파"), + Map.entry("CROSS_DOWN", "하향 돌파"), + Map.entry("SLOPE_UP", "상승 중"), + Map.entry("SLOPE_DOWN", "하락 중") + ); + + private record PendingCond(String id, JsonNode condition, String timeframe, String side) {} + + private final GcStrategyRepository strategyRepo; + private final Ta4jStorage ta4jStorage; + private final StrategyDslToTa4jAdapter adapter; + private final IndicatorSettingsService indicatorSettingsService; + private final ObjectMapper objectMapper; + private final DynamicSubscriptionManager subscriptionManager; + + @Transactional(readOnly = true) + public LiveConditionStatusDto evaluate(Long userId, String deviceId, + String market, long strategyId) { + Optional opt = strategyRepo.findById(strategyId); + if (opt.isEmpty()) { + return empty(market, strategyId); + } + GcStrategy strategy = opt.get(); + + Map> params = + indicatorSettingsService.getAll(userId, deviceId); + + List pending = new ArrayList<>(); + collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending); + collectConditions(strategy.getSellConditionJson(), "1m", "sell", pending); + + if (pending.isEmpty()) { + return empty(market, strategyId); + } + + // 데이터 수신 보장 — 평가 전 해당 종목·봉 타입 pin + 동기 warm-up + Set timeframes = new LinkedHashSet<>(); + for (PendingCond p : pending) { + String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); + timeframes.add(tf); + subscriptionManager.ensureBarsReadySync(market, tf, 60); + } + subscriptionManager.ensureBarsReadySync(market, "1m", 60); + + List rows = new ArrayList<>(); + int met = 0; + int evaluable = 0; + + for (PendingCond p : pending) { + String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); + if (!ta4jStorage.exists(market, tf)) { + rows.add(toRowUnevaluated(p)); + continue; + } + BarSeries series = ta4jStorage.getOrCreate(market, tf); + if (series.isEmpty()) { + rows.add(toRowUnevaluated(p)); + continue; + } + int index = series.getEndIndex(); + try { + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("type", "CONDITION"); + wrapper.set("condition", p.condition()); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + series, params, market, ta4jStorage); + Rule rule = adapter.toRule(wrapper, ctx); + boolean satisfied = rule.isSatisfied(index, null); + Double current = adapter.readConditionFieldValue( + p.condition(), series, params, index, true); + Double target = readTargetNumeric(p.condition()); + if (target == null) { + target = adapter.readConditionFieldValue( + p.condition(), series, params, index, false); + } + rows.add(toRow(p, current, target, satisfied, series, params, index)); + evaluable++; + if (satisfied) met++; + } catch (Exception e) { + log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage()); + rows.add(toRowUnevaluated(p)); + } + } + + int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; + String tfSummary = String.join(", ", timeframes); + + return LiveConditionStatusDto.builder() + .market(market) + .strategyId(strategyId) + .timeframe(tfSummary) + .matchRate(matchRate) + .rows(rows) + .updatedAt(System.currentTimeMillis()) + .build(); + } + + private LiveConditionStatusDto empty(String market, long strategyId) { + return LiveConditionStatusDto.builder() + .market(market) + .strategyId(strategyId) + .timeframe("") + .matchRate(0) + .rows(List.of()) + .updatedAt(System.currentTimeMillis()) + .build(); + } + + private void collectConditions(String json, String timeframe, String side, + List out) { + if (json == null || json.isBlank()) return; + try { + walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>()); + } catch (Exception e) { + log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage()); + } + } + + private void walk(JsonNode node, String timeframe, String side, + List out, Set seen) { + if (node == null || node.isNull()) return; + String type = node.path("type").asText(""); + + if ("TIMEFRAME".equals(type)) { + String tf = LiveStrategyTimeframeService.normalize( + node.path("candleType").asText(timeframe)); + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) walk(c, tf, side, out, seen); + } + return; + } + + if ("AND".equals(type) || "OR".equals(type)) { + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) walk(c, timeframe, side, out, seen); + } + return; + } + + if ("NOT".equals(type)) { + walk(node.path("child"), timeframe, side, out, seen); + return; + } + + if ("CONDITION".equals(type) && node.has("condition")) { + JsonNode cond = node.path("condition"); + String nodeId = node.path("id").asText(""); + String id = !nodeId.isBlank() + ? nodeId + "-" + side + : side + ":" + timeframe + ":" + cond.hashCode(); + String key = id + ":" + cond.toString(); + if (seen.add(key)) { + out.add(new PendingCond(id, cond, timeframe, side)); + } + } + } + + private LiveConditionRowDto toRowUnevaluated(PendingCond p) { + JsonNode c = p.condition(); + String indType = c.path("indicatorType").asText(""); + String condType = c.path("conditionType").asText("GT"); + String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); + Double target = readTargetNumeric(c); + return LiveConditionRowDto.builder() + .id(p.id()) + .indicatorType(indType) + .displayName(indType) + .conditionType(condType) + .conditionLabel(condLabel) + .thresholdLabel(formatThresholdStatic(c, target)) + .currentValue(null) + .targetValue(target) + .satisfied(null) + .timeframe(p.timeframe()) + .side(p.side()) + .build(); + } + + private String formatThresholdStatic(JsonNode cond, Double target) { + if (target != null && !target.isNaN()) { + String ct = cond.path("conditionType").asText("GT"); + String v = formatNum(target); + return switch (ct) { + case "LT", "CROSS_DOWN" -> "< " + v; + case "GT", "CROSS_UP" -> "> " + v; + case "GTE" -> "≥ " + v; + case "LTE" -> "≤ " + v; + case "EQ" -> "= " + v; + default -> cond.path("rightField").asText(v); + }; + } + String rf = cond.path("rightField").asText(""); + if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) { + return rf.replace('_', ' '); + } + return "—"; + } + + private LiveConditionRowDto toRow(PendingCond p, Double current, Double target, + Boolean satisfied, BarSeries series, + Map> params, int index) { + JsonNode c = p.condition(); + String indType = c.path("indicatorType").asText(""); + String condType = c.path("conditionType").asText("GT"); + String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); + + return LiveConditionRowDto.builder() + .id(p.id()) + .indicatorType(indType) + .displayName(indType) + .conditionType(condType) + .conditionLabel(condLabel) + .thresholdLabel(formatThreshold(c, target, series, params, index)) + .currentValue(current) + .targetValue(target) + .satisfied(satisfied) + .timeframe(p.timeframe()) + .side(p.side()) + .build(); + } + + private String formatThreshold(JsonNode cond, Double target, BarSeries series, + Map> params, int index) { + String ct = cond.path("conditionType").asText("GT"); + if (target != null && !target.isNaN()) { + String v = formatNum(target); + return switch (ct) { + case "LT", "CROSS_DOWN" -> "< " + v; + case "GT", "CROSS_UP" -> "> " + v; + case "GTE" -> "≥ " + v; + case "LTE" -> "≤ " + v; + case "EQ" -> "= " + v; + default -> cond.path("rightField").asText(v); + }; + } + String rf = cond.path("rightField").asText(""); + if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) { + Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false); + if (rightLive != null && !rightLive.isNaN()) { + String v = formatNum(rightLive); + return switch (ct) { + case "LT", "CROSS_DOWN" -> "< " + v; + case "GT", "CROSS_UP" -> "> " + v; + case "GTE" -> "≥ " + v; + case "LTE" -> "≤ " + v; + case "EQ" -> "= " + v; + default -> rf.replace('_', ' ') + " (" + v + ")"; + }; + } + return rf.replace('_', ' '); + } + return "—"; + } + + private static String formatNum(double v) { + if (Math.abs(v) >= 1000) return String.format("%.0f", v); + return String.format("%.2f", v); + } + + private Double readTargetNumeric(JsonNode cond) { + if (cond.has("targetValue") && !cond.path("targetValue").isNull()) { + return cond.path("targetValue").asDouble(); + } + if (cond.has("compareValue") && !cond.path("compareValue").isNull()) { + return cond.path("compareValue").asDouble(); + } + String rf = cond.path("rightField").asText(""); + if (rf.startsWith("K_")) { + try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {} + } + return null; + } + +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index e1332aa..7d7547d 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -49,22 +49,15 @@ public class LiveStrategySettingsService { @Transactional(readOnly = true) public List listActive(Long userId, String deviceId) { - GcAppSettings app = appSettingsService.getEntity(userId, deviceId); - if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { + List rows = userId != null + ? repo.findByUserIdAndIsLiveCheckTrue(userId) + : repo.findByDeviceIdAndIsLiveCheckTrue( + deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"); + if (rows.isEmpty()) { return List.of(); } - List watchlist = listWatchlistSymbols(userId, deviceId); - Long strategyId = app.getLiveStrategyId(); - List strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId); - return watchlist.stream() - .map(m -> LiveStrategySettingsDto.builder() - .market(m) - .strategyId(strategyId) - .liveCheck(true) - .executionType(app.getLiveExecutionType()) - .positionMode(app.getLivePositionMode()) - .strategyCandleTypes(strategyCandleTypes) - .build()) + return rows.stream() + .map(this::toDto) .toList(); } @@ -125,6 +118,17 @@ public class LiveStrategySettingsService { public LiveStrategySettingsDto save(Long userId, String deviceId, LiveStrategySettingsDto dto) { persistGlobalTemplate(userId, deviceId, dto); + + if (dto.getMarket() != null && !dto.getMarket().isBlank()) { + enrichStrategyTimeframes(dto); + LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, dto); + if (dto.isLiveCheck()) { + pinIfReady(dto.getMarket(), saved); + } else { + liveStrategyEvaluator.invalidateCache(dto.getMarket()); + } + } + syncAllWatchlistMarkets(userId, deviceId, dto); String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market); @@ -139,6 +143,10 @@ public class LiveStrategySettingsService { if (dto.getPositionMode() != null) { out.setPositionMode(dto.getPositionMode()); } + if (dto.getStrategyId() != null) { + out.setStrategyId(dto.getStrategyId()); + out.setLiveCheck(dto.isLiveCheck()); + } enrichStrategyTimeframes(out); return out; } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index c5fc28f..302d938 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -139,6 +139,39 @@ public class StrategyDslToTa4jAdapter { }; } + /** 조건 DSL left/right 필드의 현재 봉 수치 (가상투자 UI 표시용) */ + public Double readConditionFieldValue(JsonNode cond, BarSeries series, + Map> params, + int index, boolean leftSide) { + if (cond == null || cond.isNull() || series == null || index < 0) return null; + if (index >= series.getBarCount()) return null; + + String field = leftSide + ? cond.path("leftField").asText("NONE") + : cond.path("rightField").asText("NONE"); + if (field == null || field.isBlank() || "NONE".equals(field)) return null; + + String indType = cond.path("indicatorType").asText(""); + int condPeriod = cond.path("period").asInt(-1); + int leftPeriod = cond.path("leftPeriod").asInt(-1); + int rightPeriod = cond.path("rightPeriod").asInt(-1); + int sidePeriod = leftSide ? leftPeriod : rightPeriod; + + String registryKey = toRegistryKey(indType); + Map indParams = params != null + ? params.getOrDefault(registryKey, Map.of()) + : Map.of(); + + try { + Indicator ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod); + Num v = ind.getValue(index); + return v != null ? v.doubleValue() : null; + } catch (Exception e) { + log.debug("[Adapter] field value read fail {}: {}", field, e.getMessage()); + return null; + } + } + private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) { String ct = node.path("candleType").asText("1m"); BarSeries branchSeries = ctx.resolveSeries(ct); diff --git a/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java b/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java index 2a8b029..f1d80e5 100644 --- a/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java +++ b/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java @@ -147,9 +147,36 @@ public class DynamicSubscriptionManager { } } + /** + * live-conditions API 등 — 최소 봉 수 확보까지 동기 warm-up (종목별 독립). + */ + public void ensureBarsReadySync(String market, String candleType, int minBars) { + upbitWsClient.addMarket(market); + if (ta4jStorage.getBarCount(market, candleType) >= minBars) { + return; + } + String key = market + ":" + candleType; + if (warmingUp.putIfAbsent(key, Boolean.TRUE) == null) { + try { + warmUp(market, candleType); + } finally { + warmingUp.remove(key); + } + } else { + // 다른 스레드 warm-up 중 — 짧게 대기 후 재확인 + for (int i = 0; i < 30 && ta4jStorage.getBarCount(market, candleType) < minBars; i++) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + /** * 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다. - * (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨) */ public void ensureMarketPinned(String market, String candleType) { String key = market + ":" + candleType; diff --git a/backend/src/main/resources/db/migration/V28__virtual_trading_menu.sql b/backend/src/main/resources/db/migration/V28__virtual_trading_menu.sql new file mode 100644 index 0000000..3c56cd1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__virtual_trading_menu.sql @@ -0,0 +1,6 @@ +-- 가상투자 메뉴 권한 (ADMIN·USER 허용, GUEST 차단) +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'virtual', 1), +('USER', 'virtual', 1), +('GUEST', 'virtual', 0) +ON DUPLICATE KEY UPDATE allowed = VALUES(allowed); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87a71d2..cdcb7e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,6 +69,7 @@ import { BacktestResultModal } from './components/BacktestResultModal'; import { BacktestHistoryPage } from './components/BacktestHistoryPage'; import SettingsPage from './components/SettingsPage'; import PaperTradingPage from './components/PaperTradingPage'; +import VirtualTradingPage from './components/VirtualTradingPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; @@ -99,6 +100,7 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import './App.css'; import './styles/appPopup.css'; import './styles/paperDashboard.css'; +import './styles/virtualTradingDashboard.css'; import './styles/backtestDashboard.css'; let _indCounter = 0; @@ -1543,6 +1545,17 @@ function App() { /> )} + {menuPage === 'virtual' && ( + setPaperRefreshKey(k => k + 1)} + /> + )} + {/* ── 설정 화면 ──────────────────────────────────────────────────── */} {menuPage === 'notifications' && ( void; + /** favorite=별표, add=투자대상 추가(+) */ + actionMode?: 'favorite' | 'add'; + /** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */ + onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void; + /** 이미 추가된 종목 (add 모드에서 + 비활성) */ + addedMarkets?: Set; } export const MarketSearchPanel: React.FC = ({ currentMarket, onSelect, onClose, anchorRect, initialQuery = '', + variant = 'overlay', query: controlledQuery, onQueryChange, + actionMode = 'favorite', onAdd, addedMarkets, }) => { + const embedded = variant === 'embedded'; const [markets, setMarkets] = useState([]); const [tickers, setTickers] = useState>({}); - const [query, setQuery] = useState(initialQuery); + const [internalQuery, setInternalQuery] = useState(initialQuery); + const query = controlledQuery ?? internalQuery; + const setQuery = useCallback((next: string) => { + if (controlledQuery === undefined) setInternalQuery(next); + onQueryChange?.(next); + }, [controlledQuery, onQueryChange]); const [favs, setFavs] = useState>(() => new Set(getFavorites())); const [loading, setLoading] = useState(true); @@ -84,13 +103,13 @@ export const MarketSearchPanel: React.FC = ({ }) .catch(() => setLoading(false)); - setTimeout(() => inputRef.current?.focus(), 50); + if (!embedded) setTimeout(() => inputRef.current?.focus(), 50); refreshFavs(); - }, [refreshFavs]); + }, [refreshFavs, embedded]); useEffect(() => { - setQuery(initialQuery); - }, [initialQuery]); + if (controlledQuery === undefined) setInternalQuery(initialQuery); + }, [initialQuery, controlledQuery]); // 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화 useEffect(() => { @@ -145,6 +164,30 @@ export const MarketSearchPanel: React.FC = ({ } }, [markets]); + const tryAddMarket = useCallback((market: string) => { + if (actionMode === 'add') { + onSelect(market); + if (addedMarkets?.has(market)) { + if (embedded) onClose(); + return; + } + const row = markets.find(m => m.market === market); + onAdd?.(market, { + koreanName: row?.korean_name ?? market, + englishName: row?.english_name ?? market, + }); + if (embedded) onClose(); + return; + } + onSelect(market); + if (!embedded) onClose(); + }, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]); + + const handleAddClick = useCallback((market: string, e: React.MouseEvent) => { + e.stopPropagation(); + tryAddMarket(market); + }, [tryAddMarket]); + // ── 4. 필터 + 정렬 ─────────────────────────────────────────────────────── const q = query.trim(); const filtered = markets.filter(m => { @@ -176,21 +219,23 @@ export const MarketSearchPanel: React.FC = ({ // 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다. const PANEL_Z = 99999; - const panelStyle: React.CSSProperties | undefined = anchorRect - ? { - position: 'fixed', - top: anchorRect.top + HEADER_H, - left: anchorRect.left, - width: Math.max(anchorRect.width, MIN_W), - height: anchorRect.height - HEADER_H, - maxWidth: anchorRect.width, - zIndex: PANEL_Z, - // document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지 - background: 'var(--bg2, #24283b)', - color: 'var(--text, #c0caf5)', - border: '1px solid var(--border, rgba(122,162,247,0.15))', - } - : undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용 + const panelStyle: React.CSSProperties | undefined = embedded + ? undefined + : anchorRect + ? { + position: 'fixed', + top: anchorRect.top + HEADER_H, + left: anchorRect.left, + width: Math.max(anchorRect.width, MIN_W), + height: anchorRect.height - HEADER_H, + maxWidth: anchorRect.width, + zIndex: PANEL_Z, + // document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지 + background: 'var(--bg2, #24283b)', + color: 'var(--text, #c0caf5)', + border: '1px solid var(--border, rgba(122,162,247,0.15))', + } + : undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용 // 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함 const backdropStyle: React.CSSProperties | undefined = anchorRect @@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC = ({ } : undefined; + const handleRowSelect = useCallback((market: string) => { + tryAddMarket(market); + }, [tryAddMarket]); + return ( <> - {/* 배경 오버레이 (클릭 시 닫기) */} -
+ {!embedded && ( +
+ )} - {/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
- {/* 검색 입력 */} + {!embedded && (
@@ -236,10 +285,7 @@ export const MarketSearchPanel: React.FC = ({ onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Escape') onClose(); - if (e.key === 'Enter' && sorted.length > 0) { - onSelect(sorted[0].market); - onClose(); - } + if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market); }} /> {query && ( @@ -255,14 +301,18 @@ export const MarketSearchPanel: React.FC = ({
+ )} - {/* 컬럼 헤더 */} -
- +
+ {embedded ? '추가' : ''} 한글명 - 현재가 - 전일대비 - 거래대금 + {!embedded && ( + <> + 현재가 + 전일대비 + 거래대금 + + )}
{/* 목록 */} @@ -276,6 +326,7 @@ export const MarketSearchPanel: React.FC = ({ {sorted.map(m => { const tk = tickers[m.market]; const isFav = favs.has(m.market); + const isAdded = addedMarkets?.has(m.market) ?? false; const isActive = m.market === currentMarket; const rate = tk ? tk.signed_change_rate * 100 : null; const isUp = rate !== null && rate >= 0; @@ -283,40 +334,57 @@ export const MarketSearchPanel: React.FC = ({ return (
{ onSelect(m.market); onClose(); }} + className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`} + onClick={() => handleRowSelect(m.market)} > - {/* 즐겨찾기 */} - toggleFav(m.market, e)} - title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} - > - {isFav ? '★' : '☆'} - + {actionMode === 'add' ? ( + + ) : ( + toggleFav(m.market, e)} + title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} + > + {isFav ? '★' : '☆'} + + )} - {/* 이름 */} {m.korean_name} {m.symbol} - {/* 현재가 */} - - {tk ? tk.trade_price.toLocaleString() : '-'} - - - {/* 전일대비 */} - - {rate === null - ? '-' - : `${isUp ? '+' : ''}${rate.toFixed(2)}%`} - - - {/* 거래대금 */} - - {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} - + {!embedded && ( + <> + + {tk ? tk.trade_price.toLocaleString() : '-'} + + + {rate === null + ? '-' + : `${isUp ? '+' : ''}${rate.toFixed(2)}%`} + + + {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + + + )}
); })} diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 3764d12..6cf93d9 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import { canAccessMenu } from '../utils/permissions'; -export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings'; +export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings'; interface TopMenuBarProps { activePage: MenuPage; @@ -129,10 +129,19 @@ const IcStrategyEditor = () => ( ); +const IcVirtual = () => ( + + + + + +); + const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ { page: 'dashboard', label: '대시보드', icon: }, { page: 'chart', label: '실시간차트', icon: }, { page: 'paper', label: '모의투자', icon: }, + { page: 'virtual', label: '가상투자', icon: }, { page: 'strategy-editor', label: '전략편집기', icon: }, { page: 'backtest', label: '백테스팅', icon: }, { page: 'settings', label: '설정', icon: }, diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx new file mode 100644 index 0000000..11494db --- /dev/null +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -0,0 +1,336 @@ +/** + * 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링 + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + loadPaperSummary, + loadStrategies, + saveLiveStrategySettings, + type PaperSummaryDto, + type StrategyDto, +} from '../utils/backendApi'; +import type { Theme, TradeOrderFillRequest } from '../types'; +import TradeOrderPanel from './TradeOrderPanel'; +import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; +import PaperSplitPanel from './paper/PaperSplitPanel'; +import BuilderPageShell from './layout/BuilderPageShell'; +import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel'; +import VirtualTargetGrid from './virtual/VirtualTargetGrid'; +import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; +import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; +import { + loadVirtualSession, + loadVirtualTargets, + saveVirtualSession, + saveVirtualTargets, + type VirtualSessionConfig, + type VirtualTargetItem, +} from '../utils/virtualTradingStorage'; +import '../styles/virtualTradingDashboard.css'; + +type RightTab = 'trade' | 'orderbook'; + +interface Props { + theme?: Theme; + tickers?: Map; + defaultMarket?: string; + paperTradingEnabled?: boolean; + paperAutoTradeEnabled?: boolean; + onPaperOrderFilled?: () => void; +} + +function coinCode(symbol: string): string { + return symbol.replace(/^KRW-/, ''); +} + +const VirtualTradingPage: React.FC = ({ + theme = 'dark', + tickers, + defaultMarket = 'KRW-BTC', + paperTradingEnabled = true, + paperAutoTradeEnabled = false, + onPaperOrderFilled, +}) => { + const [targets, setTargets] = useState(() => loadVirtualTargets()); + const [session, setSession] = useState(() => loadVirtualSession()); + const [strategies, setStrategies] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedMarket, setSelectedMarket] = useState(defaultMarket); + const [rightTab, setRightTab] = useState('trade'); + const [fillBuy, setFillBuy] = useState(null); + const [fillSell, setFillSell] = useState(null); + const orderAnchorRef = useRef(null); + + useEffect(() => { + loadStrategies().then(setStrategies).catch(() => setStrategies([])); + loadPaperSummary().then(setSummary).finally(() => setLoading(false)); + }, []); + + useEffect(() => { saveVirtualTargets(targets); }, [targets]); + useEffect(() => { saveVirtualSession(session); }, [session]); + + const targetRefs = useMemo(() => + targets.map(t => ({ + market: t.market, + strategyId: t.strategyId ?? session.globalStrategyId, + })), + [targets, session.globalStrategyId]); + + const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running); + const liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running); + + const mergedLiveStatus = useMemo(() => { + if (!session.running) return liveStatusByMarket; + const merged = { ...liveStatusByMarket }; + const now = Date.now(); + for (const [market, snap] of Object.entries(snapshots)) { + if (snap?.updatedAt && now - snap.updatedAt < 8000) { + merged[market] = 'live'; + } + } + return merged; + }, [liveStatusByMarket, snapshots, session.running]); + + const posQty = useMemo(() => { + const p = summary?.positions?.find(x => x.symbol === selectedMarket); + return p?.quantity ?? 0; + }, [summary?.positions, selectedMarket]); + + const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null; + + const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => { + setTargets(items); + if (items.length > 0 && !items.some(t => t.market === selectedMarket)) { + setSelectedMarket(items[0].market); + } + }, [selectedMarket]); + + const handleGlobalStrategyChange = useCallback((strategyId: number | null) => { + setSession(s => ({ ...s, globalStrategyId: strategyId })); + }, []); + + const handleStart = useCallback(async () => { + if (targets.length === 0) { + window.alert('투자대상 종목을 먼저 추가하세요.'); + return; + } + if (!session.globalStrategyId) { + window.alert('투자전략을 선택하세요.'); + return; + } + const template = { + isLiveCheck: true, + executionType: session.executionType, + positionMode: session.positionMode, + }; + try { + await Promise.all(targets.map(t => + saveLiveStrategySettings({ + market: t.market, + strategyId: t.strategyId ?? session.globalStrategyId, + ...template, + }), + )); + setSession(s => ({ ...s, running: true })); + } catch { + window.alert('가상투자 시작 설정 저장에 실패했습니다.'); + } + }, [targets, session]); + + const handleStop = useCallback(async () => { + if (targets.length === 0) { + setSession(s => ({ ...s, running: false })); + return; + } + try { + await Promise.all(targets.map(t => + saveLiveStrategySettings({ + market: t.market, + strategyId: t.strategyId ?? session.globalStrategyId, + isLiveCheck: false, + executionType: session.executionType, + positionMode: session.positionMode, + }), + )); + } catch { /* ignore */ } + setSession(s => ({ ...s, running: false })); + }, [targets, session]); + + const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { + const side = rowType === 'ask' ? 'buy' : 'sell'; + const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; + if (side === 'buy') setFillBuy(req); else setFillSell(req); + setRightTab('trade'); + }, [selectedMarket]); + + const headerControls = ( +
+ + + + {!session.running ? ( + + ) : ( + + )} +
+ ); + + const rightTabs = ( +
+ + +
+ ); + + return ( + + )} + center={( + + )} + rightTabs={rightTabs} + right={( +
+ {rightTab === 'trade' ? ( + + )} + bottom={( + + )} + /> + ) : ( + + )} + bottom={( + + )} + /> + )} +
+ )} + /> + ); +}; + +export default VirtualTradingPage; diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx index 9f80175..e2b5796 100644 --- a/frontend/src/components/layout/BuilderPageShell.tsx +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { Theme } from '../../types'; import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize'; import '../../styles/strategyEditorTheme.css'; @@ -7,6 +7,9 @@ import '../../styles/builderPageShell.css'; const LEFT_MIN = 220; const LEFT_MAX = 520; const LEFT_DEFAULT = 280; +const RIGHT_MIN = 260; +const RIGHT_MAX = 520; +const RIGHT_DEFAULT = 280; const FOOTER_MIN = 88; const FOOTER_MAX = 420; const FOOTER_DEFAULT = 140; @@ -29,6 +32,9 @@ export interface BuilderPageShellProps { footerLabel?: string; footer?: React.ReactNode; leftStorageKey?: string; + leftDefaultWidth?: number; + rightStorageKey?: string; + rightDefaultWidth?: number; footerStorageKey?: string; loading?: boolean; loadingText?: string; @@ -52,15 +58,21 @@ export default function BuilderPageShell({ footerLabel, footer, leftStorageKey = 'bps-left-width', + leftDefaultWidth = LEFT_DEFAULT, + rightStorageKey = 'bps-right-width', + rightDefaultWidth = RIGHT_DEFAULT, footerStorageKey = 'bps-footer-height', loading = false, loadingText = '로딩 중…', }: BuilderPageShellProps) { - const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT)); + const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, leftDefaultWidth)); + const [rightWidth, setRightWidth] = useState(() => readStoredSize(rightStorageKey, rightDefaultWidth)); const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT)); const leftWidthRef = useRef(leftWidth); + const rightWidthRef = useRef(rightWidth); const footerHeightRef = useRef(footerHeight); leftWidthRef.current = leftWidth; + rightWidthRef.current = rightWidth; footerHeightRef.current = footerHeight; const onLeftSplitter = usePanelResize( @@ -72,6 +84,37 @@ export default function BuilderPageShell({ v => storeSize(leftStorageKey, v), ); + const handleRightSplitter = useCallback((e: React.PointerEvent) => { + const startX = e.clientX; + const start = rightWidthRef.current; + const cursor = 'col-resize'; + + document.body.style.cursor = cursor; + document.body.style.userSelect = 'none'; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.classList.add('se-splitter--active'); + const splitter = e.currentTarget; + + const onMove = (ev: PointerEvent) => { + const delta = ev.clientX - startX; + const next = Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, start - delta)); + setRightWidth(next); + }; + + const onUp = (ev: PointerEvent) => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + splitter.releasePointerCapture(ev.pointerId); + splitter.classList.remove('se-splitter--active'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + storeSize(rightStorageKey, rightWidthRef.current); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }, [rightStorageKey]); + const onFooterSplitter = usePanelResize( 'horizontal', setFooterHeight, @@ -165,14 +208,26 @@ export default function BuilderPageShell({ {right && ( - + <> +
+ + )}
diff --git a/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx b/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx new file mode 100644 index 0000000..9a7effc --- /dev/null +++ b/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import type { ConditionMetric } from '../../utils/virtualSignalMetrics'; +import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics'; +import { formatIndicatorValue } from '../../utils/virtualStrategyConditions'; + +interface Props { + metrics: ConditionMetric[]; +} + +function StatusBadge({ status }: { status: ConditionMetric['status'] }) { + switch (status) { + case 'match': + return ( + + + + + Match + + ); + case 'pending': + return ( + + + + + + Pending + + ); + case 'nomatch': + return ( + + + + + No Match + + ); + default: + return ; + } +} + +const VirtualIndicatorCompareTable: React.FC = ({ metrics }) => ( +
+
REAL-TIME INDICATOR COMPARISON
+ + + + + + + + + + + + {metrics.map(({ row, status, progress }) => ( + + + + + + + + ))} + +
TECHNICAL INDICATORCURRENT VALUESTRATEGY THRESHOLDPROGRESSSTATUS
{row.displayName}{formatIndicatorValue(row.currentValue)} + {row.thresholdLabel + ?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)} + +
+
+
+ {progress != null ? `${Math.round(progress)}%` : '—'} +
+
+); + +export default VirtualIndicatorCompareTable; diff --git a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx new file mode 100644 index 0000000..cf228e6 --- /dev/null +++ b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx @@ -0,0 +1,165 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MarketSearchPanel } from '../MarketSearchPanel'; +import { getKoreanName } from '../../utils/marketNameCache'; +import type { StrategyDto } from '../../utils/backendApi'; +import type { VirtualTargetItem } from '../../utils/virtualTradingStorage'; + +interface Props { + targets: VirtualTargetItem[]; + globalStrategyId: number | null; + strategies: StrategyDto[]; + selectedMarket: string; + onTargetsChange: (items: VirtualTargetItem[]) => void; + onSelectMarket: (market: string) => void; +} + +const VirtualLeftTargetPanel: React.FC = ({ + targets, + globalStrategyId, + strategies, + selectedMarket, + onTargetsChange, + onSelectMarket, +}) => { + const [query, setQuery] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const searchRef = useRef(null); + + const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]); + + const closeDropdown = useCallback(() => { + setShowDropdown(false); + }, []); + + const openDropdown = useCallback(() => { + setShowDropdown(true); + }, []); + + useEffect(() => { + if (!showDropdown) return; + const onDocMouseDown = (e: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', onDocMouseDown); + return () => document.removeEventListener('mousedown', onDocMouseDown); + }, [showDropdown]); + + const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => { + if (addedSet.has(market)) return; + onTargetsChange([ + ...targets, + { + market, + strategyId: globalStrategyId, + koreanName: meta.koreanName, + englishName: meta.englishName, + }, + ]); + onSelectMarket(market); + setQuery(''); + setShowDropdown(false); + }, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]); + + const handleRemove = useCallback((market: string) => { + onTargetsChange(targets.filter(t => t.market !== market)); + }, [targets, onTargetsChange]); + + const handleStrategyChange = useCallback((market: string, strategyId: number | null) => { + onTargetsChange(targets.map(t => + t.market === market ? { ...t, strategyId } : t, + )); + }, [targets, onTargetsChange]); + + return ( +
+
+
검색 (실시간 차트와 동일 목록)
+ { + setQuery(e.target.value); + setShowDropdown(true); + }} + onFocus={openDropdown} + onClick={openDropdown} + /> + {showDropdown && ( +
+ +
+ )} +
+ +
+
+ 투자대상 + {targets.length}종목 +
+ +
+ {targets.length === 0 && ( +

검색 후 + 버튼으로 종목을 추가하세요.

+ )} + {targets.map(item => { + const ko = item.koreanName ?? getKoreanName(item.market); + const en = item.englishName ?? item.market.replace(/^KRW-/, ''); + const active = item.market === selectedMarket; + return ( +
onSelectMarket(item.market)} + > +
+
+ {ko} + {en} +
+ +
+ +
+ ); + })} +
+
+
+ ); +}; + +export default VirtualLeftTargetPanel; diff --git a/frontend/src/components/virtual/VirtualLiveBadge.tsx b/frontend/src/components/virtual/VirtualLiveBadge.tsx new file mode 100644 index 0000000..546a26d --- /dev/null +++ b/frontend/src/components/virtual/VirtualLiveBadge.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; + +interface Props { + status: VirtualLiveStatus; +} + +const LABEL: Record = { + idle: null, + connecting: '연결 중', + live: '실시간', + disconnected: '연결 끊김', +}; + +const VirtualLiveBadge: React.FC = ({ status }) => { + const label = LABEL[status]; + if (!label) return null; + + return ( + + + {label} + + ); +}; + +export default VirtualLiveBadge; diff --git a/frontend/src/components/virtual/VirtualSignalEqualizer.tsx b/frontend/src/components/virtual/VirtualSignalEqualizer.tsx new file mode 100644 index 0000000..31ccbcf --- /dev/null +++ b/frontend/src/components/virtual/VirtualSignalEqualizer.tsx @@ -0,0 +1,64 @@ +import React, { useMemo } from 'react'; + +const SEGMENTS = 25; + +interface Props { + matchRate: number; +} + +const VirtualSignalEqualizer: React.FC = ({ matchRate }) => { + const litCount = useMemo( + () => Math.round((Math.min(100, Math.max(0, matchRate)) / 100) * SEGMENTS), + [matchRate], + ); + + const segments = useMemo(() => { + const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = []; + for (let i = 0; i < SEGMENTS; i++) { + const lit = i < litCount; + if (!lit) { + items.push({ lit: false, tone: 'blue' }); + continue; + } + if (matchRate >= 100 && i === SEGMENTS - 1) { + items.push({ lit: true, tone: 'peak' }); + } else if (i >= Math.floor(SEGMENTS * 0.55)) { + items.push({ lit: true, tone: 'gold' }); + } else { + items.push({ lit: true, tone: 'blue' }); + } + } + return items; + }, [litCount, matchRate]); + + const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0]; + + return ( +
+
+ {ticks.map(t => ( + {t}% + ))} +
+
+
+ {segments.map((seg, i) => ( +
+ ))} +
+ {matchRate >= 100 && ( +
100% MATCH
+ )} +
+
+ ); +}; + +export default VirtualSignalEqualizer; diff --git a/frontend/src/components/virtual/VirtualSignalTrafficLight.tsx b/frontend/src/components/virtual/VirtualSignalTrafficLight.tsx new file mode 100644 index 0000000..7415c9a --- /dev/null +++ b/frontend/src/components/virtual/VirtualSignalTrafficLight.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { TrafficLightState } from '../../utils/virtualSignalMetrics'; + +interface Props { + state: TrafficLightState; + matchRate: number; +} + +const LABELS: Record = { + red: '조건 미충족', + yellow: '부분 일치 / 대기', + blue: '시그널 일치 (100%)', +}; + +const VirtualSignalTrafficLight: React.FC = ({ state, matchRate }) => ( +
+
+
+
+
+
+
+ {matchRate}% + {LABELS[state]} +
+
+); + +export default VirtualSignalTrafficLight; diff --git a/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx b/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx new file mode 100644 index 0000000..806010b --- /dev/null +++ b/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx @@ -0,0 +1,206 @@ +/** + * 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표) + */ +import React, { + useCallback, useEffect, useMemo, useRef, useState, +} from 'react'; +import AppPopup from '../AppPopup'; +import TradingChart from '../TradingChart'; +import type { StrategyDto } from '../../utils/backendApi'; +import { pinCandleWatch } from '../../utils/backendApi'; +import type { + ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, +} from '../../types'; +import { getKoreanName } from '../../utils/marketNameCache'; +import { isUpbitMarket } from '../../utils/upbitApi'; +import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import type { ChartManager } from '../../utils/ChartManager'; +import { + buildStrategyChartIndicators, + resolveStrategyPrimaryTimeframe, + resolveStrategyTimeframes, +} from '../../utils/strategyToChartIndicators'; +import { timeframeToCandleType } from '../../utils/chartCandleType'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; + +interface Props { + market: string; + strategy: StrategyDto | undefined; + theme?: Theme; + running?: boolean; + onClose: () => void; +} + +const noop = () => {}; + +function WsBadge({ status }: { status: WsStatus }) { + const map: Record = { + connecting: { cls: 'vtd-chart-ws--connecting', label: '연결 중' }, + connected: { cls: 'vtd-chart-ws--live', label: '실시간' }, + disconnected: { cls: 'vtd-chart-ws--off', label: '연결 끊김' }, + error: { cls: 'vtd-chart-ws--off', label: '오류' }, + }; + const { cls, label } = map[status]; + return ( + + + {label} + + ); +} + +const VirtualStrategyChartPopup: React.FC = ({ + market, + strategy, + theme = 'dark', + running = false, + onClose, +}) => { + const { getParams, getVisualConfig } = useIndicatorSettings(); + const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); + const [timeframe, setTimeframe] = useState(() => resolveStrategyPrimaryTimeframe(strategy)); + + useEffect(() => { + setTimeframe(resolveStrategyPrimaryTimeframe(strategy)); + }, [strategy, market]); + + const indicators = useMemo( + () => buildStrategyChartIndicators(strategy, getParams, getVisualConfig), + [strategy, getParams, getVisualConfig], + ); + + const managerRef = useRef(null); + const pendingBarRef = useRef(null); + const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + const marketRef = useRef(market); + marketRef.current = market; + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; + pendingBarRef.current = null; + mgr.updateBar(pending); + }, []); + + const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { + if (barsMarketRef.current !== marketRef.current) return; + if (!chartLiveReadyRef.current || !managerRef.current) { + pendingBarRef.current = bar; + return; + } + if (append) managerRef.current.appendBar(bar); + else managerRef.current.updateBar(bar); + }, []); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, false); + }, [applyRealtimeBar]); + + const handleNewCandle = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, true); + }, [applyRealtimeBar]); + + const useUpbit = isUpbitMarket(market); + + const { bars, barsMarket, wsStatus, isLoading } = useChartRealtimeData( + market, + timeframe, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + }), [handleTickUpdate, handleNewCandle, useUpbit]), + ); + + barsMarketRef.current = barsMarket; + + useEffect(() => { + chartLiveReadyRef.current = false; + pendingBarRef.current = null; + }, [market, timeframe]); + + useEffect(() => { + if (!useUpbit) return; + void pinCandleWatch(market, timeframeToCandleType(timeframe)); + if (timeframeToCandleType(timeframe) !== '1m') { + void pinCandleWatch(market, '1m'); + } + }, [market, timeframe, useUpbit, running]); + + const ko = getKoreanName(market); + const sym = market.replace(/^KRW-/, ''); + const stratName = strategy?.name ?? '전략 미지정'; + + return ( + : undefined} + > +
+ {stratName} + {indicators.length > 0 && ( + 보조지표 {indicators.length}개 + )} +
+ +
+ {tfOptions.map(tf => ( + + ))} +
+ +
+ {isLoading &&
차트 로딩…
} + void} + onManagerReady={mgr => { managerRef.current = mgr; }} + onAddDrawing={noop as (d: Drawing) => void} + onCandlesReady={handleCandlesReady} + magnifierEnabled={false} + /> +
+ + {indicators.length === 0 && ( +

+ 전략에 차트로 표시할 보조지표가 없습니다. +

+ )} +
+ ); +}; + +export default VirtualStrategyChartPopup; diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx new file mode 100644 index 0000000..91d2efb --- /dev/null +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -0,0 +1,101 @@ +import React, { useMemo } from 'react'; +import { getKoreanName } from '../../utils/marketNameCache'; +import type { StrategyDto } from '../../utils/backendApi'; +import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; +import { + buildConditionMetrics, + computeMatchRate, + formatUpdatedTime, + getTrafficLightState, +} from '../../utils/virtualSignalMetrics'; +import VirtualLiveBadge from './VirtualLiveBadge'; +import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; +import VirtualSignalEqualizer from './VirtualSignalEqualizer'; +import VirtualSignalTrafficLight from './VirtualSignalTrafficLight'; +import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage'; +import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable'; + +interface Props { + market: string; + strategy: StrategyDto | undefined; + snapshot: VirtualIndicatorSnapshot | undefined; + running: boolean; + liveStatus?: VirtualLiveStatus; + viewMode?: VirtualCardViewMode; + onOpenChart?: () => void; +} + +const VirtualTargetCard: React.FC = ({ + market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart, +}) => { + const ko = getKoreanName(market); + const sym = market.replace(/^KRW-/, ''); + const tf = snapshot?.timeframe ?? '—'; + const rows = snapshot?.rows ?? []; + + const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); + const matchRate = useMemo( + () => computeMatchRate(metrics, snapshot?.matchRate), + [metrics, snapshot?.matchRate], + ); + const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]); + const metCount = metrics.filter(m => m.status === 'match').length; + const evalCount = metrics.filter(m => m.status !== 'unknown').length; + + const isDetail = viewMode === 'detail'; + + return ( +
+
+
+ {ko} + {sym} +
+
+ + {strategy?.name ?? '전략 미지정'} +
+
+ +
+ 시간봉 {tf} + {isDetail && evalCount > 0 && ( + {metCount}/{evalCount} 조건 충족 + )} + {running && } +
+ + {rows.length === 0 ? ( +

전략 조건·지표 데이터 없음

+ ) : ( + <> +
+
SIGNAL INTELLIGENCE & MATCH RATES
+
+ + +
+ {isDetail && } +
+
+ 갱신 {formatUpdatedTime(snapshot?.updatedAt)} + 매매조건 일치율 {matchRate}% +
+ + )} +
+ ); +}; + +export default VirtualTargetCard; diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx new file mode 100644 index 0000000..4d15a53 --- /dev/null +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import VirtualTargetCard from './VirtualTargetCard'; +import VirtualStrategyChartPopup from './VirtualStrategyChartPopup'; +import type { StrategyDto } from '../../utils/backendApi'; +import type { Theme } from '../../types'; +import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage'; +import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage'; +import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; +import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; + +interface Props { + targets: VirtualTargetItem[]; + strategies: StrategyDto[]; + snapshots: Record; + running: boolean; + globalStrategyId: number | null; + liveStatusByMarket?: Record; + theme?: Theme; +} + +const VirtualTargetGrid: React.FC = ({ + targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark', +}) => { + const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); + const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null); + + useEffect(() => { + saveVirtualCardViewMode(viewMode); + }, [viewMode]); + + if (targets.length === 0) { + return ( +
+

좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.

+
+ ); + } + + return ( + <> +
+
+ 종목별 매매시그널 일치 현황 +
+
+ + +
+ {viewMode === 'detail' && ( +
+ ● 충족 + ● 대기 + ● 미충족 +
+ )} + {running && 실시간 갱신 3초} +
+
+
+ {targets.map(t => { + const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId)); + return ( + setChartTarget({ market: t.market, strategy: strat })} + /> + ); + })} +
+
+ {chartTarget && ( + setChartTarget(null)} + /> + )} + + ); +}; + +export default VirtualTargetGrid; diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts new file mode 100644 index 0000000..69b2a6f --- /dev/null +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -0,0 +1,213 @@ +/** + * 가상투자 카드 — 종목×전략별 지표·조건 스냅샷 + * running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립) + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { StrategyDto } from '../utils/backendApi'; +import { + fetchLiveConditionStatus, + pinCandleWatch, + type LiveConditionRowDto, +} from '../utils/backendApi'; +import { + extractVirtualConditions, + type VirtualConditionRow, +} from '../utils/virtualStrategyConditions'; + +export interface VirtualIndicatorSnapshot { + market: string; + strategyId: number; + timeframe: string; + rows: Array; + updatedAt: number; + /** 백엔드 Ta4j 집계 일치율 (0~100) */ + matchRate?: number; +} + +function rowFallbackKey(row: Pick): string { + return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`; +} + +function liveFallbackKey(r: LiveConditionRowDto): string { + return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`; +} + +function mergeRows( + base: VirtualConditionRow[], + live: LiveConditionRowDto[], +): Array { + const liveById = new Map(); + const liveByKey = new Map(); + for (const r of live) { + liveById.set(r.id, r); + liveByKey.set(liveFallbackKey(r), r); + } + + if (base.length === 0) { + return live.map(r => ({ + id: r.id, + indicatorType: r.indicatorType, + displayName: r.displayName, + conditionType: r.conditionType, + conditionLabel: r.conditionLabel, + targetValue: r.targetValue, + timeframe: r.timeframe, + side: r.side, + plotKey: r.indicatorType, + satisfied: r.satisfied, + thresholdLabel: r.thresholdLabel, + currentValue: r.currentValue, + })); + } + + return base.map(row => { + const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row)); + if (!liveRow) { + return { ...row, currentValue: null as number | null }; + } + return { + ...row, + satisfied: liveRow.satisfied, + thresholdLabel: liveRow.thresholdLabel, + currentValue: liveRow.currentValue, + targetValue: liveRow.targetValue ?? row.targetValue, + }; + }); +} + +async function buildSnapshot( + market: string, + strategyId: number, + strategy: StrategyDto | undefined, + running: boolean, +): Promise { + const baseRows = strategy ? extractVirtualConditions(strategy) : []; + + if (!running) { + if (baseRows.length === 0) return null; + return { + market, + strategyId, + timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: baseRows.map(r => ({ ...r, currentValue: null })), + updatedAt: Date.now(), + matchRate: 0, + }; + } + + const status = await fetchLiveConditionStatus(market, strategyId); + if (!status || status.market !== market) { + if (baseRows.length === 0) return null; + return { + market, + strategyId, + timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: baseRows.map(r => ({ ...r, currentValue: null })), + updatedAt: Date.now(), + matchRate: 0, + }; + } + + return { + market, + strategyId, + timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: mergeRows(baseRows, status.rows), + updatedAt: status.updatedAt || Date.now(), + matchRate: status.matchRate, + }; +} + +interface TargetRef { + market: string; + strategyId: number | null; +} + +export function useVirtualIndicatorSnapshots( + targets: TargetRef[], + strategies: StrategyDto[], + running: boolean, + pollMs = 3000, +): Record { + const [snapshots, setSnapshots] = useState>({}); + const strategiesRef = useRef(strategies); + strategiesRef.current = strategies; + const pinnedRef = useRef>(new Set()); + const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|'); + + const pinTargets = useCallback(async () => { + const strats = strategiesRef.current; + const pins = new Set(); + for (const t of targets) { + if (t.strategyId == null) continue; + const strat = strats.find(s => s.id === t.strategyId); + const conditions = strat ? extractVirtualConditions(strat) : []; + const tfs = conditions.length > 0 + ? [...new Set(conditions.map(c => c.timeframe))] + : ['1m']; + for (const tf of tfs) { + const key = `${t.market}:${tf}`; + pins.add(key); + if (!pinnedRef.current.has(key)) { + await pinCandleWatch(t.market, tf); + pinnedRef.current.add(key); + } + } + const k1 = `${t.market}:1m`; + if (!pins.has(k1) || !tfs.includes('1m')) { + if (!pinnedRef.current.has(k1)) { + await pinCandleWatch(t.market, '1m'); + pinnedRef.current.add(k1); + } + } + } + for (const k of pinnedRef.current) { + if (!pins.has(k)) pinnedRef.current.delete(k); + } + }, [targets]); + + const refresh = useCallback(async () => { + const strats = strategiesRef.current; + if (running) await pinTargets(); + + const jobs = targets + .filter(t => t.strategyId != null) + .map(async t => { + const strat = strats.find(s => s.id === t.strategyId); + const snap = await buildSnapshot(t.market, t.strategyId!, strat, running); + if (snap) { + setSnapshots(prev => ({ ...prev, [snap.market]: snap })); + } + return snap; + }); + await Promise.all(jobs); + + // 제거된 종목 스냅샷 정리 + const activeMarkets = new Set(targets.map(t => t.market)); + setSnapshots(prev => { + const next = { ...prev }; + let changed = false; + for (const key of Object.keys(next)) { + if (!activeMarkets.has(key)) { + delete next[key]; + changed = true; + } + } + return changed ? next : prev; + }); + }, [targets, running, pinTargets]); + + useEffect(() => { + if (targets.length === 0) { + setSnapshots({}); + pinnedRef.current.clear(); + return; + } + void refresh(); + if (!running) return; + const id = window.setInterval(() => void refresh(), pollMs); + return () => clearInterval(id); + }, [targetsKey, running, pollMs, refresh]); + + return snapshots; +} diff --git a/frontend/src/hooks/useVirtualTargetLiveStatus.ts b/frontend/src/hooks/useVirtualTargetLiveStatus.ts new file mode 100644 index 0000000..6962ff0 --- /dev/null +++ b/frontend/src/hooks/useVirtualTargetLiveStatus.ts @@ -0,0 +1,119 @@ +/** + * 가상투자 — 종목별 STOMP 실시간 데이터 수신 상태 + */ +import { useEffect, useRef, useState } from 'react'; +import { pinCandleWatch } from '../utils/backendApi'; +import { parseStompJson } from '../utils/stompMessage'; +import { + isStompChartConnected, + subscribeStompConnection, + subscribeStompTopic, +} from '../utils/stompChartBroker'; + +/** idle=미시작, connecting=연결 중, live=틱 수신 중, disconnected=끊김 */ +export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected'; + +const STALE_MS = 20_000; +const TICK_CANDLE = '1m'; + +interface TargetRef { + market: string; + strategyId: number | null; +} + +interface MarketLiveState { + status: VirtualLiveStatus; + lastTickAt: number | null; +} + +function topicFor(market: string): string { + return `/sub/charts/${market}/${TICK_CANDLE}`; +} + +export function useVirtualTargetLiveStatus( + targets: TargetRef[], + running: boolean, +): Record { + const [byMarket, setByMarket] = useState>({}); + const stateRef = useRef>({}); + const stompConnectedRef = useRef(isStompChartConnected()); + + const patchMarket = (market: string, patch: Partial) => { + const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null }; + const next: MarketLiveState = { ...prev, ...patch }; + stateRef.current[market] = next; + setByMarket(cur => ({ ...cur, [market]: next.status })); + }; + + const recomputeStatus = (market: string) => { + const s = stateRef.current[market]; + if (!s || !running) return; + const now = Date.now(); + if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) { + if (s.status !== 'live') patchMarket(market, { status: 'live' }); + return; + } + if (stompConnectedRef.current) { + patchMarket(market, { status: 'connecting' }); + } else { + patchMarket(market, { status: 'disconnected' }); + } + }; + + useEffect(() => { + if (!running) { + stateRef.current = {}; + setByMarket({}); + return; + } + + const activeMarkets = targets + .filter(t => t.strategyId != null) + .map(t => t.market); + + for (const market of activeMarkets) { + if (!stateRef.current[market]) { + stateRef.current[market] = { status: 'connecting', lastTickAt: null }; + setByMarket(cur => ({ ...cur, [market]: 'connecting' })); + } + void pinCandleWatch(market, TICK_CANDLE); + } + + for (const key of Object.keys(stateRef.current)) { + if (!activeMarkets.includes(key)) { + delete stateRef.current[key]; + setByMarket(cur => { + const next = { ...cur }; + delete next[key]; + return next; + }); + } + } + + const unsubs = activeMarkets.map(market => { + const topic = topicFor(market); + return subscribeStompTopic(topic, msg => { + const data = parseStompJson<{ time?: number }>(msg); + if (data?.time == null) return; + patchMarket(market, { status: 'live', lastTickAt: Date.now() }); + }); + }); + + const offConn = subscribeStompConnection(next => { + stompConnectedRef.current = next; + for (const market of activeMarkets) recomputeStatus(market); + }); + + const staleId = window.setInterval(() => { + for (const market of activeMarkets) recomputeStatus(market); + }, 3000); + + return () => { + unsubs.forEach(u => u()); + offConn(); + clearInterval(staleId); + }; + }, [targets, running]); + + return byMarket; +} diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css new file mode 100644 index 0000000..f91ac7d --- /dev/null +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -0,0 +1,1077 @@ +/* 가상투자 대시보드 */ + +.vtd-header-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; +} + +.vtd-header-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--se-text-muted, #888); +} + +.vtd-header-field span { + white-space: nowrap; +} + +.vtd-header-select, +.vtd-left-select { + min-width: 140px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--se-border); + background: var(--se-panel-card-bg); + color: var(--se-text); + font-size: 12px; +} + +.vtd-left { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + min-height: 0; +} + +.vtd-search-section { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; + position: relative; + min-width: 0; +} + +.vtd-search-label { + font-size: 11px; + font-weight: 600; + color: var(--se-text-muted); +} + +.vtd-search-input { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--se-border); + background: var(--se-panel-card-bg); + color: var(--se-text); + font-size: 12px; + flex-shrink: 0; +} + +.vtd-search-input--open, +.vtd-search-input:focus { + border-color: var(--accent, #3f7ef5); + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 25%, transparent); +} + +.vtd-search-dropdown { + position: relative; + top: auto; + left: 0; + right: 0; + z-index: 30; + flex: 1; + min-height: 0; + max-height: none; +} + +.vtd-search-dropdown .msp-panel--embedded { + position: relative; + top: auto; + left: auto; + width: 100%; + height: 100%; + max-height: none; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +/* 드롭다운 펼침: 검색 입력 아래 ~ 좌측 패널 하단까지 */ +.vtd-left--dropdown-open .vtd-search-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.vtd-left--dropdown-open .vtd-target-section { + flex-shrink: 0; +} + +.vtd-left--dropdown-open .vtd-target-list { + display: none; +} + +.vtd-search-dropdown .msp-header--embedded { + grid-template-columns: 40px 1fr; + padding: 4px 8px; + font-size: 10px; + background: var(--se-panel-card-bg); + border-bottom: 1px solid var(--se-border); +} + +.vtd-search-dropdown .msp-item--embedded { + grid-template-columns: 40px 1fr; + padding: 6px 8px; + border-bottom: 1px solid var(--se-border); +} + +.vtd-search-dropdown .msp-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.vtd-target-section { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; +} + +.vtd-target-list-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 600; + color: var(--se-text-muted); +} + +.vtd-target-count { + font-weight: 500; + font-size: 11px; +} + +.vtd-target-list { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.vtd-target-item { + padding: 10px; + border-radius: 10px; + border: 1px solid var(--se-border); + background: var(--se-panel-card-bg); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.vtd-target-item--active { + border-color: var(--accent, #3f7ef5); + background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--se-panel-card-bg)); +} + +.vtd-target-item-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.vtd-target-names { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.vtd-target-ko { + font-size: 13px; + font-weight: 600; + color: var(--se-text); +} + +.vtd-target-en { + font-size: 10px; + color: var(--se-text-muted); +} + +.vtd-target-remove { + flex-shrink: 0; + width: 22px; + height: 22px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--se-text-muted); + cursor: pointer; + font-size: 12px; +} + +.vtd-target-remove:hover { + background: color-mix(in srgb, var(--down, #ef5350) 15%, transparent); + color: var(--down, #ef5350); +} + +.vtd-target-strat { + display: block; + margin-top: 8px; +} + +.vtd-target-strat select { + width: 100%; +} + +.vtd-muted { + font-size: 12px; + color: var(--se-text-muted); + margin: 0; +} + +/* 중앙 그리드 — 헤더 고정, 카드 목록만 세로 스크롤 */ +.bps-center-content .vtd-grid-wrap, +.bps-center-content .vtd-grid-empty { + flex: 1; + min-height: 0; +} + +.bps-center-content:has(.vtd-grid-wrap) { + overflow: hidden; + padding-bottom: 0; +} + +.vtd-grid-wrap { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.vtd-grid-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: 2px 4px 6px; + background: var(--se-center-bg, var(--se-panel-card-bg)); +} + +.vtd-grid-head-title { + font-size: 12px; + font-weight: 700; + color: var(--se-text); + flex-shrink: 0; +} + +.vtd-grid-head-right { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.vtd-view-toggle { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--se-border); + border-radius: 8px; + overflow: hidden; + background: color-mix(in srgb, var(--se-panel-card-bg) 80%, transparent); +} + +.vtd-view-toggle-btn { + padding: 5px 12px; + border: none; + background: transparent; + color: var(--se-text-muted); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.vtd-view-toggle-btn + .vtd-view-toggle-btn { + border-left: 1px solid var(--se-border); +} + +.vtd-view-toggle-btn:hover:not(.vtd-view-toggle-btn--on) { + background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, transparent); + color: var(--se-text); +} + +.vtd-view-toggle-btn--on { + background: color-mix(in srgb, var(--accent, #3f7ef5) 18%, var(--se-panel-card-bg)); + color: var(--accent, #3f7ef5); +} + +.vtd-grid-head-legend { + display: flex; + align-items: center; + gap: 10px; + font-size: 10px; + color: var(--se-text-muted); +} + +.vtd-legend--match { color: var(--up, #26a69a); } +.vtd-legend--pending { color: #ffb300; } +.vtd-legend--nomatch { color: var(--down, #ef5350); } + +.vtd-grid-live { + color: var(--up, #26a69a); + font-weight: 600; +} + +.vtd-grid-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.vtd-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + padding: 4px 4px 12px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + align-content: start; +} + +@media (max-width: 1280px) { + .vtd-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .vtd-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.vtd-card { + min-width: 0; + box-sizing: border-box; + border: 1px solid var(--se-border); + border-radius: 12px; + background: var(--se-panel-card-bg); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.vtd-card--signal { + border-color: color-mix(in srgb, #c9a227 35%, var(--se-border)); + background: linear-gradient( + 165deg, + color-mix(in srgb, #1a2035 92%, #000) 0%, + var(--se-panel-card-bg) 55% + ); +} + +.vtd-card--live { + border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent); +} + +.vtd-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.vtd-card-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.vtd-card-chart-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border)); + border-radius: 8px; + background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent); + color: var(--accent, #3f7ef5); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.vtd-card-chart-btn:hover { + background: color-mix(in srgb, var(--accent, #3f7ef5) 22%, transparent); + border-color: var(--accent, #3f7ef5); +} + +.vtd-card-chart-btn:active { + transform: scale(0.96); +} + +.vtd-card-title { + display: flex; + flex-direction: column; + gap: 2px; +} + +.vtd-card-ko { + font-size: 14px; + font-weight: 700; +} + +.vtd-card-sym { + font-size: 10px; + color: var(--se-text-muted); +} + +.vtd-card-strat { + font-size: 11px; + color: var(--accent, #3f7ef5); + text-align: right; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vtd-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 10px; + color: var(--se-text-muted); +} + +.vtd-card-empty { + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +/* 종목별 실시간 수신 배지 */ +.vtd-live-badge { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.vtd-live-badge-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.vtd-live-badge--live { + color: #26a69a; +} + +.vtd-live-badge--live .vtd-live-badge-dot { + background: #26a69a; + box-shadow: 0 0 6px 2px color-mix(in srgb, #26a69a 75%, transparent); + animation: vtd-live-pulse 2s ease-in-out infinite; +} + +.vtd-live-badge--connecting { + color: #787b86; +} + +.vtd-live-badge--connecting .vtd-live-badge-dot { + background: #787b86; + animation: vtd-live-blink 1.2s ease-in-out infinite; +} + +.vtd-live-badge--disconnected { + color: #787b86; +} + +.vtd-live-badge--disconnected .vtd-live-badge-dot { + background: color-mix(in srgb, #787b86 50%, transparent); + border: 1px solid #787b86; +} + +@keyframes vtd-live-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 6px 2px color-mix(in srgb, #26a69a 75%, transparent); } + 50% { opacity: 0.85; box-shadow: 0 0 10px 4px color-mix(in srgb, #26a69a 90%, transparent); } +} + +@keyframes vtd-live-blink { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } +} + +.vtd-card--summary { + padding-bottom: 10px; +} + +.vtd-sig-panel--summary .vtd-sig-visual { + padding: 10px 16px 12px; +} + +.vtd-card--summary .vtd-card-foot { + margin-top: 2px; +} + +.vtd-card-met-count { + color: color-mix(in srgb, #c9a227 80%, var(--se-text)); + font-weight: 600; +} + +.vtd-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + color: var(--se-text-muted); + padding-top: 4px; + border-top: 1px solid var(--se-border); +} + +.vtd-card-match-summary { + font-weight: 700; + color: var(--accent, #3f7ef5); +} + +/* 시그널 일치율 패널 — 카드 높이 고정·축소 방지, 전체 UI 항상 표시 */ +.vtd-sig-panel { + display: flex; + flex-direction: column; + gap: 10px; + flex-shrink: 0; +} + +.vtd-sig-panel-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + color: color-mix(in srgb, #c9a227 90%, #fff); + text-align: center; + padding: 4px 0; +} + +.vtd-sig-visual { + display: flex; + align-items: stretch; + justify-content: center; + gap: 20px; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, #c9a227 30%, var(--se-border)); + border-radius: 10px; + background: color-mix(in srgb, #0d111f 60%, transparent); +} + +/* 세그먼트 이퀄라이저 */ +.vtd-sig-eq { + display: flex; + gap: 8px; + align-items: stretch; + flex: 1; + max-width: 120px; +} + +.vtd-sig-eq-scale { + display: flex; + flex-direction: column; + justify-content: space-between; + font-size: 8px; + color: var(--se-text-muted); + padding: 2px 0; + line-height: 1; +} + +.vtd-sig-eq-bar-wrap { + position: relative; + flex: 1; + min-width: 36px; +} + +.vtd-sig-eq-bar { + display: flex; + flex-direction: column-reverse; + gap: 2px; + height: 180px; + padding: 4px; + border: 1px solid color-mix(in srgb, #c9a227 40%, var(--se-border)); + border-radius: 6px; + background: color-mix(in srgb, #000 35%, transparent); +} + +.vtd-sig-eq-seg { + flex: 1; + min-height: 3px; + border-radius: 2px; + background: color-mix(in srgb, var(--se-text-muted) 15%, transparent); + transition: background 0.25s ease, box-shadow 0.25s ease; +} + +.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue { + background: linear-gradient(90deg, #1e6fd9, #4fc3f7); + box-shadow: 0 0 6px color-mix(in srgb, #4fc3f7 50%, transparent); +} + +.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--gold { + background: linear-gradient(90deg, #b8860b, #ffd54f); + box-shadow: 0 0 6px color-mix(in srgb, #ffd54f 45%, transparent); +} + +.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--peak { + background: linear-gradient(90deg, #1565c0, #82b1ff); + box-shadow: 0 0 10px color-mix(in srgb, #82b1ff 70%, transparent); +} + +.vtd-sig-eq-badge { + position: absolute; + right: -8px; + top: -4px; + transform: translateX(100%); + font-size: 9px; + font-weight: 800; + color: #82b1ff; + white-space: nowrap; + text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent); +} + +/* 신호등 */ +.vtd-sig-light { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + justify-content: center; +} + +.vtd-sig-light-housing { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 2px solid color-mix(in srgb, #555 80%, #888); + border-radius: 14px; + background: linear-gradient(180deg, #2a2a2a, #1a1a1a); +} + +.vtd-sig-light-bulb { + width: 28px; + height: 28px; + border-radius: 50%; + opacity: 0.25; + transition: opacity 0.25s, box-shadow 0.25s; +} + +.vtd-sig-light-bulb--red { background: radial-gradient(circle at 35% 35%, #ff8a80, #c62828); } +.vtd-sig-light-bulb--yellow { background: radial-gradient(circle at 35% 35%, #fff59d, #f9a825); } +.vtd-sig-light-bulb--blue { background: radial-gradient(circle at 35% 35%, #82b1ff, #1565c0); } + +.vtd-sig-light-bulb--on { + opacity: 1; +} + +.vtd-sig-light-bulb--red.vtd-sig-light-bulb--on { + box-shadow: 0 0 14px 4px color-mix(in srgb, #ef5350 70%, transparent); +} + +.vtd-sig-light-bulb--yellow.vtd-sig-light-bulb--on { + box-shadow: 0 0 14px 4px color-mix(in srgb, #ffb300 70%, transparent); +} + +.vtd-sig-light-bulb--blue.vtd-sig-light-bulb--on { + box-shadow: 0 0 18px 6px color-mix(in srgb, #4fc3f7 80%, transparent); +} + +.vtd-sig-light-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.vtd-sig-light-rate { + font-size: 22px; + font-weight: 800; + color: var(--se-text); + line-height: 1; +} + +.vtd-sig-light-label { + font-size: 10px; + color: var(--se-text-muted); + max-width: 140px; + line-height: 1.3; +} + +/* 지표 비교 테이블 */ +.vtd-sig-table-wrap { + border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border)); + border-radius: 8px; + overflow: hidden; + background: color-mix(in srgb, #0d111f 50%, transparent); +} + +.vtd-sig-table-head { + padding: 6px 10px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + color: color-mix(in srgb, #c9a227 90%, #fff); + border-bottom: 1px solid color-mix(in srgb, #c9a227 25%, var(--se-border)); + background: color-mix(in srgb, #c9a227 6%, transparent); +} + +.vtd-sig-table { + width: 100%; + border-collapse: collapse; + font-size: 10px; +} + +.vtd-sig-table th { + text-align: left; + padding: 6px 8px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.04em; + color: color-mix(in srgb, #c9a227 80%, var(--se-text-muted)); + border-bottom: 1px solid var(--se-border); + white-space: nowrap; +} + +.vtd-sig-table td { + padding: 7px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent); + vertical-align: middle; +} + +.vtd-sig-table tbody tr:last-child td { + border-bottom: none; +} + +.vtd-sig-table-ind { + font-weight: 600; + color: var(--se-text); +} + +.vtd-sig-table-val { + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.vtd-sig-table-threshold { + color: var(--se-text-muted); + font-variant-numeric: tabular-nums; +} + +.vtd-sig-table-progress { + min-width: 88px; +} + +.vtd-sig-row-bar { + height: 6px; + border-radius: 3px; + background: color-mix(in srgb, var(--se-text-muted) 20%, transparent); + overflow: hidden; + margin-bottom: 2px; +} + +.vtd-sig-row-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.vtd-sig-row-bar-fill--match { + background: linear-gradient(90deg, var(--up, #26a69a), color-mix(in srgb, var(--up, #26a69a) 70%, #fff)); +} + +.vtd-sig-row-bar-fill--pending { + background: linear-gradient(90deg, #f9a825, #ffd54f); +} + +.vtd-sig-row-bar-fill--nomatch { + background: linear-gradient(90deg, var(--down, #ef5350), color-mix(in srgb, var(--down, #ef5350) 70%, #fff)); +} + +.vtd-sig-row-bar-fill--unknown { + background: var(--se-border); +} + +.vtd-sig-row-pct { + font-size: 9px; + color: var(--se-text-muted); +} + +.vtd-sig-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 9px; + font-weight: 700; + white-space: nowrap; +} + +.vtd-sig-status--match { color: var(--up, #26a69a); } +.vtd-sig-status--pending { color: #ffb300; } +.vtd-sig-status--nomatch { color: var(--down, #ef5350); } +.vtd-sig-status--unknown { color: var(--se-text-muted); } + +/* 구 이퀄라이저 (미사용) */ +.vtd-eq { + flex: 1; + display: flex; + align-items: flex-end; + justify-content: flex-start; + gap: 6px; + min-height: 100px; + padding-top: 8px; + overflow-x: auto; +} + +.vtd-eq-col { + flex: 0 0 44px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + height: 120px; +} + +.vtd-eq-bar { + width: 28px; + min-height: 8px; + margin-top: auto; + border-radius: 4px 4px 2px 2px; + background: var(--se-text-muted); + transition: height 0.25s ease; +} + +.vtd-eq-bar--ok { + background: linear-gradient(to top, var(--up, #26a69a), color-mix(in srgb, var(--up, #26a69a) 60%, #fff)); +} + +.vtd-eq-bar--ng { + background: linear-gradient(to top, var(--down, #ef5350), color-mix(in srgb, var(--down, #ef5350) 60%, #fff)); +} + +.vtd-eq-bar--na { + background: var(--se-border); +} + +.vtd-eq-name { + font-size: 9px; + font-weight: 600; + text-align: center; + line-height: 1.2; + max-width: 44px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vtd-eq-val { + font-size: 10px; + font-weight: 700; + color: var(--se-text); +} + +.vtd-eq-target { + font-size: 9px; + color: var(--se-text-muted); +} + +/* MarketSearchPanel + 버튼 */ +.msp-add-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 0 auto; + padding: 0; + border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 45%, var(--se-border, #333)); + border-radius: 6px; + background: color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent); + color: var(--accent, #3f7ef5); + cursor: pointer; + flex-shrink: 0; +} + +.msp-add-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #3f7ef5) 22%, transparent); + border-color: var(--accent, #3f7ef5); +} + +.msp-add-btn--done { + background: color-mix(in srgb, var(--up, #26a69a) 12%, transparent); + border-color: color-mix(in srgb, var(--up, #26a69a) 45%, var(--se-border, #333)); + color: var(--up, #26a69a); + cursor: default; +} + +.msp-add-btn:disabled { + opacity: 1; +} + +.msp-add { + color: var(--accent, #3f7ef5); + font-weight: 700; + font-size: 16px; + cursor: pointer; + user-select: none; +} + +.msp-add--done { + color: var(--up, #26a69a); + font-size: 13px; +} + +.msp-add:hover:not(.msp-add--done) { + color: color-mix(in srgb, var(--accent, #3f7ef5) 80%, #fff); +} + +/* 차트 팝업 */ +.vtd-chart-popup-body { + padding: 10px 14px 14px !important; +} + +.vtd-chart-popup-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 11px; +} + +.vtd-chart-popup-strat { + font-weight: 700; + color: var(--accent, #3f7ef5); +} + +.vtd-chart-popup-ind-count { + color: var(--se-text-muted); +} + +.vtd-chart-popup-tf-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.vtd-chart-popup-tf { + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--se-border); + background: transparent; + color: var(--se-text-muted); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.vtd-chart-popup-tf--on { + border-color: var(--accent, #3f7ef5); + background: color-mix(in srgb, var(--accent, #3f7ef5) 15%, transparent); + color: var(--accent, #3f7ef5); +} + +.vtd-chart-popup-canvas-wrap { + position: relative; + height: min(58vh, 520px); + min-height: 360px; + border: 1px solid var(--se-border); + border-radius: 10px; + overflow: hidden; + background: var(--se-center-bg, #131722); +} + +.vtd-chart-popup-canvas-wrap .chart-container { + height: 100% !important; +} + +.vtd-chart-popup-loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--se-center-bg, #131722) 75%, transparent); + font-size: 12px; + color: var(--se-text-muted); +} + +.vtd-chart-popup-empty { + margin: 8px 0 0; + text-align: center; +} + +.vtd-chart-ws { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 700; +} + +.vtd-chart-ws-dot { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.vtd-chart-ws--live { + color: #26a69a; +} + +.vtd-chart-ws--live .vtd-chart-ws-dot { + background: #26a69a; + box-shadow: 0 0 6px color-mix(in srgb, #26a69a 70%, transparent); +} + +.vtd-chart-ws--connecting { + color: #787b86; +} + +.vtd-chart-ws--connecting .vtd-chart-ws-dot { + background: #787b86; +} + +.vtd-chart-ws--off { + color: #787b86; +} + +.vtd-chart-ws--off .vtd-chart-ws-dot { + background: #787b86; + opacity: 0.5; +} diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 24c7dca..90493f7 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1066,3 +1066,56 @@ export async function saveLiveStrategySettingsBulk( })) ?? []; return list; } + +// ───────────────────────────────────────────────────────────────────────────── +// 가상투자 — 실시간 조건 충족 현황 (Ta4j Rule.isSatisfied) +// ───────────────────────────────────────────────────────────────────────────── + +export interface LiveConditionRowDto { + id: string; + indicatorType: string; + displayName: string; + conditionType: string; + conditionLabel: string; + thresholdLabel: string; + currentValue: number | null; + targetValue: number | null; + satisfied: boolean | null; + timeframe: string; + side: 'buy' | 'sell'; +} + +export interface LiveConditionStatusDto { + market: string; + strategyId: number; + timeframe: string; + matchRate: number; + rows: LiveConditionRowDto[]; + updatedAt: number; +} + +/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */ +export async function fetchLiveConditionStatus( + market: string, + strategyId: number, +): Promise { + const params = new URLSearchParams({ + market, + strategyId: String(strategyId), + }); + return request(`/strategy/live-conditions?${params}`, { + cache: 'no-store', + }); +} + +/** 차트 실시간 파이프라인 pin (STOMP warm-up) */ +export async function pinCandleWatch(market: string, candleType: string): Promise { + try { + await fetch( + `${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, + { method: 'POST' }, + ); + } catch { + /* pin 실패해도 평가 API는 시도 */ + } +} diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index c5e982d..176c383 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -12,7 +12,7 @@ export type SettingsCategoryId = | 'paper' | 'alert' | 'network' | 'admin'; export const TOP_MENU_IDS: TopMenuId[] = [ - 'dashboard', 'chart', 'paper', 'strategy-editor', 'backtest', 'settings', + 'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings', ]; export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ @@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ ]; export const ALL_MENU_IDS = [ - 'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings', + 'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings', ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const), ] as const; @@ -30,6 +30,7 @@ export const MENU_LABELS: Record = { dashboard: '대시보드', chart: '실시간차트', paper: '모의투자', + virtual: '가상투자', strategy: '투자전략', 'strategy-editor': '전략편집기', backtest: '백테스팅', diff --git a/frontend/src/utils/stompChartBroker.ts b/frontend/src/utils/stompChartBroker.ts index 6d9fc1a..8bc7957 100644 --- a/frontend/src/utils/stompChartBroker.ts +++ b/frontend/src/utils/stompChartBroker.ts @@ -7,6 +7,7 @@ import SockJS from 'sockjs-client'; import { getStompSockJsUrl } from './backendApi'; type MessageHandler = (msg: IMessage) => void; +type ConnectionListener = (connected: boolean) => void; interface TopicEntry { handlers: Set; @@ -16,8 +17,14 @@ interface TopicEntry { let client: StompClient | null = null; let connected = false; const topics = new Map(); +const connectionListeners = new Set(); let disconnectTimer: ReturnType | null = null; +function notifyConnection(next: boolean): void { + connected = next; + connectionListeners.forEach(l => l(next)); +} + function handlerCount(): number { let n = 0; for (const entry of topics.values()) n += entry.handlers.size; @@ -51,12 +58,12 @@ function getOrCreateClient(): StompClient { heartbeatIncoming: 10_000, heartbeatOutgoing: 10_000, onConnect: () => { - connected = true; cancelPendingDisconnect(); + notifyConnection(true); resubscribeAll(); }, onDisconnect: () => { - connected = false; + notifyConnection(false); for (const entry of topics.values()) { entry.stompSub = undefined; } @@ -68,7 +75,7 @@ function getOrCreateClient(): StompClient { console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev); }, onWebSocketClose: () => { - connected = false; + notifyConnection(false); for (const entry of topics.values()) { entry.stompSub = undefined; } @@ -95,10 +102,17 @@ function scheduleDisconnect(): void { return; } client.deactivate(); - connected = false; + notifyConnection(false); }, 5000); } +/** STOMP 연결 상태 변경 구독 */ +export function subscribeStompConnection(listener: ConnectionListener): () => void { + connectionListeners.add(listener); + listener(connected && Boolean(client?.connected)); + return () => { connectionListeners.delete(listener); }; +} + /** * STOMP 토픽 구독 (참조 카운트). 반환 함수로 해제. */ diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts new file mode 100644 index 0000000..fbd1fc1 --- /dev/null +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -0,0 +1,246 @@ +/** + * 전략 DSL → 차트 IndicatorConfig[] 변환 (가상투자 차트 팝업) + */ +import type { StrategyDto } from './backendApi'; +import type { IndicatorConfig, Timeframe } from '../types'; +import type { LogicNode } from './strategyTypes'; +import { + enrichIndicatorConfig, + getIndicatorDef, + getHLineLabel, + type HLineDef, +} from './indicatorRegistry'; +import { createDefaultSmaPlotVisibility, normalizeSmaConfig, smaPeriodKey } from './smaConfig'; +import { normalizeIchimokuConfig } from './ichimokuConfig'; +import { extractVirtualConditions } from './virtualStrategyConditions'; + +const DSL_TO_REGISTRY: Record = { + RSI: 'RSI', + MACD: 'MACD', + MA: 'SMA', + EMA: 'EMA', + BOLLINGER: 'BollingerBands', + STOCHASTIC: 'Stochastic', + WILLIAMS_R: 'WilliamsPercentRange', + CCI: 'CCI', + ADX: 'ADX', + DMI: 'DMI', + OBV: 'OBV', + TRIX: 'TRIX', + VOLUME_OSC: 'VolumeOscillator', + VR: 'VR', + DISPARITY: 'Disparity', + PSYCHOLOGICAL: 'Psychological', + NEW_PSYCHOLOGICAL: 'Psychological', + INVEST_PSYCHOLOGICAL: 'InvestPsychological', + BWI: 'BBBandWidth', + DONCHIAN: 'DonchianChannels', + ICHIMOKU: 'IchimokuCloud', + ATR: 'ATR', + MFI: 'MFI', +}; + +interface IndicatorRef { + dslType: string; + registryType: string; + period?: number; + leftPeriod?: number; + rightPeriod?: number; + targetValue?: number | null; +} + +type ParamRecord = Record; +type GetParams = (type: string, defaults?: ParamRecord) => ParamRecord; +type GetVisual = ( + type: string, + plots: import('./indicatorRegistry').PlotDef[], + hlines: HLineDef[], +) => { + plots: import('./indicatorRegistry').PlotDef[]; + hlines: HLineDef[]; + cloudColors?: import('./ichimokuConfig').IchimokuCloudColors; +}; + +let _idSeq = 0; +function newIndId(): string { + _idSeq += 1; + return `vstrat_${_idSeq}_${Date.now()}`; +} + +export function candleTypeToTimeframe(candleType: string): Timeframe { + const c = (candleType ?? '1m').trim().toLowerCase(); + if (c === '1d') return '1D'; + if (c === '1h') return '1h'; + if (c === '4h') return '4h'; + if (['1m', '3m', '5m', '15m', '30m'].includes(c)) return c as Timeframe; + return '1m'; +} + +export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Timeframe[] { + if (!strategy) return ['1m']; + const conditions = extractVirtualConditions(strategy); + const set = new Set(); + for (const row of conditions) { + set.add(candleTypeToTimeframe(row.timeframe)); + } + if (set.size === 0) set.add('1m'); + return [...set].sort((a, b) => { + const order: Timeframe[] = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1D', '1W', '1M']; + return order.indexOf(a) - order.indexOf(b); + }); +} + +export function resolveStrategyPrimaryTimeframe(strategy: StrategyDto | undefined): Timeframe { + const tfs = resolveStrategyTimeframes(strategy); + return tfs[0] ?? '1m'; +} + +function walkIndicatorRefs( + node: LogicNode | null | undefined, + out: IndicatorRef[], + seen: Set, +): void { + if (!node) return; + + if (node.type === 'TIMEFRAME') { + node.children?.forEach(c => walkIndicatorRefs(c, out, seen)); + return; + } + + if (node.type === 'CONDITION' && node.condition) { + const c = node.condition; + const dslType = c.indicatorType; + const registryType = DSL_TO_REGISTRY[dslType] ?? dslType; + const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${c.targetValue ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ + dslType, + registryType, + period: c.period, + leftPeriod: c.leftPeriod, + rightPeriod: c.rightPeriod, + targetValue: c.targetValue ?? c.compareValue ?? null, + }); + return; + } + + node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen)); +} + +function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] { + const out: IndicatorRef[] = []; + const seen = new Set(); + walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen); + walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen); + return out; +} + +function applyTargetHlines( + hlines: HLineDef[], + target: number | null | undefined, +): HLineDef[] { + if (target == null || Number.isNaN(target)) return hlines; + const next = hlines.map(h => ({ ...h })); + const prices = next.map(h => h.price); + const idx = next.findIndex(h => Math.abs(h.price - target) < 1e-6); + if (idx >= 0) { + next[idx] = { ...next[idx], visible: true, price: target }; + return next; + } + next.push({ + price: target, + color: '#ffb300', + label: getHLineLabel(target, [...prices, target]), + visible: true, + lineStyle: 'dashed', + lineWidth: 1, + }); + return next; +} + +function buildOneIndicator( + ref: IndicatorRef, + getParams: GetParams, + getVisual: GetVisual, +): IndicatorConfig | null { + const def = getIndicatorDef(ref.registryType); + if (!def) return null; + + const params = getParams(ref.registryType, def.defaultParams as ParamRecord); + const { plots, hlines, cloudColors } = getVisual(ref.registryType, def.plots, def.hlines ?? []); + + let cfg: IndicatorConfig = { + id: newIndId(), + type: def.type, + params: { ...params }, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + }; + + if (ref.registryType === 'SMA') { + const periods = [ref.period, ref.leftPeriod, ref.rightPeriod] + .filter((p): p is number => typeof p === 'number' && p > 0); + if (periods.length > 0) { + const p = { ...cfg.params }; + periods.slice(0, 11).forEach((val, i) => { + p[smaPeriodKey(i + 1)] = val; + }); + cfg = { ...cfg, params: p }; + } + cfg = normalizeSmaConfig({ + ...cfg, + plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), + }); + } else if (ref.period != null && ref.period > 0) { + cfg = { + ...cfg, + params: { ...cfg.params, length: ref.period }, + }; + } + + if (ref.registryType === 'IchimokuCloud') { + cfg = normalizeIchimokuConfig(cfg); + } + + if (ref.targetValue != null) { + cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) }; + } + + return enrichIndicatorConfig(cfg); +} + +/** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */ +export function buildStrategyChartIndicators( + strategy: StrategyDto | undefined, + getParams: GetParams, + getVisual: GetVisual, +): IndicatorConfig[] { + if (!strategy) return []; + + const refs = collectIndicatorRefs(strategy); + const byType = new Map(); + + for (const ref of refs) { + const existing = byType.get(ref.registryType); + if (!existing) { + byType.set(ref.registryType, ref); + continue; + } + byType.set(ref.registryType, { + ...existing, + period: existing.period ?? ref.period, + leftPeriod: existing.leftPeriod ?? ref.leftPeriod, + rightPeriod: existing.rightPeriod ?? ref.rightPeriod, + targetValue: existing.targetValue ?? ref.targetValue, + }); + } + + const result: IndicatorConfig[] = []; + for (const ref of byType.values()) { + const cfg = buildOneIndicator(ref, getParams, getVisual); + if (cfg) result.push(cfg); + } + return result; +} diff --git a/frontend/src/utils/virtualSignalMetrics.ts b/frontend/src/utils/virtualSignalMetrics.ts new file mode 100644 index 0000000..ed55c88 --- /dev/null +++ b/frontend/src/utils/virtualSignalMetrics.ts @@ -0,0 +1,154 @@ +import { + formatIndicatorValue, + isConditionMet, + type VirtualConditionRow, +} from './virtualStrategyConditions'; + +export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown'; + +export interface ConditionMetric { + row: VirtualConditionRow & { currentValue: number | null }; + status: ConditionStatus; + progress: number | null; +} + +export type TrafficLightState = 'red' | 'yellow' | 'blue'; + +const PENDING_PROGRESS = 72; + +/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */ +export function computeConditionProgress( + conditionType: string, + current: number | null, + target: number | null, +): number | null { + if (current == null || target == null) return null; + const met = isConditionMet(conditionType, current, target); + if (met === true) return 100; + + const absTarget = Math.abs(target); + const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(current), 1); + + switch (conditionType) { + case 'LT': + case 'CROSS_DOWN': + case 'LTE': { + if (current <= target) return 100; + const gap = current - target; + return Math.min(99, Math.max(0, 100 - (gap / scale) * 100)); + } + case 'GT': + case 'CROSS_UP': + case 'GTE': { + if (current >= target) return 100; + if (target <= 0 && current <= 0) return 0; + return Math.min(99, Math.max(0, (current / scale) * 100)); + } + case 'EQ': { + const diff = Math.abs(current - target); + return Math.min(99, Math.max(0, 100 - (diff / scale) * 100)); + } + default: + return Math.min(99, Math.max(0, 100 - (Math.abs(current - target) / scale) * 100)); + } +} + +export function getConditionStatus( + conditionType: string, + current: number | null, + target: number | null, + satisfied?: boolean | null, +): ConditionStatus { + if (satisfied === true) return 'match'; + if (satisfied === false) { + const progress = computeConditionProgress(conditionType, current, target); + if (progress != null && progress >= PENDING_PROGRESS) return 'pending'; + return 'nomatch'; + } + const met = isConditionMet(conditionType, current, target); + if (met === true) return 'match'; + if (met === false) { + const progress = computeConditionProgress(conditionType, current, target); + if (progress != null && progress >= PENDING_PROGRESS) return 'pending'; + return 'nomatch'; + } + return 'unknown'; +} + +export function formatStrategyThreshold( + conditionType: string, + target: number | null, + conditionLabel: string, +): string { + if (target == null) return '—'; + const v = formatIndicatorValue(target); + switch (conditionType) { + case 'LT': + case 'CROSS_DOWN': + return `< ${v}`; + case 'GT': + case 'CROSS_UP': + return `> ${v}`; + case 'GTE': + return `≥ ${v}`; + case 'LTE': + return `≤ ${v}`; + case 'EQ': + return `= ${v}`; + case 'NEQ': + return `≠ ${v}`; + default: + return `${conditionLabel} ${v}`; + } +} + +export function buildConditionMetrics( + rows: Array, +): ConditionMetric[] { + return rows.map(row => ({ + row, + status: getConditionStatus( + row.conditionType, + row.currentValue, + row.targetValue, + row.satisfied, + ), + progress: row.satisfied === true + ? 100 + : row.satisfied === false + ? (computeConditionProgress(row.conditionType, row.currentValue, row.targetValue) ?? 0) + : computeConditionProgress(row.conditionType, row.currentValue, row.targetValue), + })); +} + +/** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */ +export function computeMatchRate( + metrics: ConditionMetric[], + backendMatchRate?: number, +): number { + if (backendMatchRate != null && Number.isFinite(backendMatchRate)) { + return Math.round(Math.min(100, Math.max(0, backendMatchRate))); + } + const evaluable = metrics.filter(m => m.status !== 'unknown'); + if (evaluable.length === 0) return 0; + const met = evaluable.filter(m => m.status === 'match').length; + return Math.round((met / evaluable.length) * 100); +} + +export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState { + if (matchRate >= 100) return 'blue'; + const hasPending = metrics.some(m => m.status === 'pending'); + const hasPartial = metrics.some(m => m.status === 'match'); + if (hasPending || hasPartial || matchRate >= 40) return 'yellow'; + return 'red'; +} + +export function formatUpdatedTime(ts: number | undefined): string { + if (!ts) return '—'; + return new Date(ts).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts new file mode 100644 index 0000000..00d84b5 --- /dev/null +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -0,0 +1,100 @@ +/** + * 전략 DSL → 가상투자 이퀄라이저 표시용 조건 추출 + */ +import type { LogicNode } from './strategyTypes'; +import { CONDITION_LABEL } from './strategyTypes'; +import type { StrategyDto } from './backendApi'; +import { getIndicatorDef } from './indicatorRegistry'; + +export interface VirtualConditionRow { + id: string; + indicatorType: string; + displayName: string; + conditionType: string; + conditionLabel: string; + targetValue: number | null; + timeframe: string; + side: 'buy' | 'sell'; + /** 지표 계산 plot id (RSI, MACD 등) */ + plotKey: string; + /** 백엔드 Ta4j Rule.isSatisfied 결과 (있으면 우선) */ + satisfied?: boolean | null; + /** 백엔드 임계값 라벨 (MA cross 등) */ + thresholdLabel?: string; +} + +function walk( + node: LogicNode | null | undefined, + timeframe: string, + side: 'buy' | 'sell', + out: VirtualConditionRow[], + seen: Set, +): void { + if (!node) return; + + if (node.type === 'TIMEFRAME') { + const tf = node.candleType ?? timeframe; + node.children?.forEach(c => walk(c, tf, side, out, seen)); + return; + } + + if (node.type === 'CONDITION' && node.condition) { + const c = node.condition; + const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + + const def = getIndicatorDef(c.indicatorType); + const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType; + const target = c.targetValue ?? c.compareValue ?? null; + const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType; + + out.push({ + id: `${node.id}-${side}`, + indicatorType: c.indicatorType, + displayName: def?.koreanName ?? def?.shortName ?? c.indicatorType, + conditionType: c.conditionType, + conditionLabel: condLabel, + targetValue: target, + timeframe, + side, + plotKey, + }); + return; + } + + node.children?.forEach(c => walk(c, timeframe, side, out, seen)); +} + +export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] { + if (!strategy) return []; + const out: VirtualConditionRow[] = []; + const seen = new Set(); + walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen); + walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen); + return out; +} + +/** 조건 충족 여부 (단순 임계값 비교) */ +export function isConditionMet( + conditionType: string, + current: number | null, + target: number | null, +): boolean | null { + if (current == null || target == null) return null; + switch (conditionType) { + case 'GT': case 'CROSS_UP': return current > target; + case 'LT': case 'CROSS_DOWN': return current < target; + case 'GTE': return current >= target; + case 'LTE': return current <= target; + case 'EQ': return Math.abs(current - target) < 1e-6; + case 'NEQ': return Math.abs(current - target) >= 1e-6; + default: return null; + } +} + +export function formatIndicatorValue(v: number | null): string { + if (v == null || Number.isNaN(v)) return '—'; + if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return v.toFixed(2); +} diff --git a/frontend/src/utils/virtualTradingStorage.ts b/frontend/src/utils/virtualTradingStorage.ts new file mode 100644 index 0000000..a9ff7a1 --- /dev/null +++ b/frontend/src/utils/virtualTradingStorage.ts @@ -0,0 +1,86 @@ +/** 가상투자 대상 종목·세션 설정 localStorage */ + +export interface VirtualTargetItem { + market: string; + strategyId: number | null; + koreanName?: string; + englishName?: string; +} + +export interface VirtualSessionConfig { + globalStrategyId: number | null; + executionType: 'CANDLE_CLOSE' | 'REALTIME_TICK'; + /** LONG_ONLY = 보유 자산 기준, SIGNAL_ONLY = 순수 지표 기준 */ + positionMode: 'LONG_ONLY' | 'SIGNAL_ONLY'; + running: boolean; +} + +const TARGETS_KEY = 'gc_virtual_targets_v1'; +const SESSION_KEY = 'gc_virtual_session_v1'; +const CARD_VIEW_KEY = 'gc_virtual_card_view_v1'; + +/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */ +export type VirtualCardViewMode = 'summary' | 'detail'; + +export function loadVirtualTargets(): VirtualTargetItem[] { + try { + const raw = localStorage.getItem(TARGETS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as VirtualTargetItem[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function saveVirtualTargets(items: VirtualTargetItem[]): void { + try { + localStorage.setItem(TARGETS_KEY, JSON.stringify(items)); + } catch { /* ignore */ } +} + +export function loadVirtualSession(): VirtualSessionConfig { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return defaultSession(); + const parsed = JSON.parse(raw) as Partial; + return { + globalStrategyId: parsed.globalStrategyId ?? null, + executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE', + positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY', + running: parsed.running === true, + }; + } catch { + return defaultSession(); + } +} + +export function saveVirtualSession(cfg: VirtualSessionConfig): void { + try { + localStorage.setItem(SESSION_KEY, JSON.stringify(cfg)); + } catch { /* ignore */ } +} + +function defaultSession(): VirtualSessionConfig { + return { + globalStrategyId: null, + executionType: 'CANDLE_CLOSE', + positionMode: 'LONG_ONLY', + running: false, + }; +} + +export function loadVirtualCardViewMode(): VirtualCardViewMode { + try { + const raw = localStorage.getItem(CARD_VIEW_KEY); + return raw === 'detail' ? 'detail' : 'summary'; + } catch { + return 'summary'; + } +} + +export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void { + try { + localStorage.setItem(CARD_VIEW_KEY, mode); + } catch { /* ignore */ } +} diff --git a/image/stock_status_ui.png b/image/stock_status_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..258364c09f8c433db595876393c88d21e270eb15 GIT binary patch literal 1590533 zcmeFa2UJu^*Dl;m5)cIy6?3Z?Te?r@ZpF|!H#D6w(K+Xa4mt{$bIyu6jWgze85J>$ zIfpT!jyaCmzYgHM&b;%5d)If@z5iOy8s|{w)T!G0*?T{GSDlg>lP%%aOKhHqKp^Vz zd8`}+A`bi*gNTm;msO2o8zT_j0eUS%4PR~fP93=28e9!}yhn>@fJiAsAbv(r=Xs;6 zZ-8FZ+Fsn6Ue3Sv%J;dt!#%qjbwr#FPr_$FcmgSdNYWWHbTm9AgKWYRO?ZlqLegr% zTy0ZphwD-^oYwzulVY@fztI=4x?O(oLc;0V7ka=i^f{J<-#M1IU}fE|7hVr#S{uQ0 zW2}0&3v@-K>G6IG+FN(lZ==@K|D5Q#-nC-sJ8-|L&ScObtWK@ji1ink(IKY;9yzG9 z8g!Z91}bPZ1S~zph+SF zDR3AZkqRCYFeDn8iKl?sA$T}Ec#R08AYq6k6dnkRr{Pg}A`p^Xd%-blZo{&|y}%^Z zZ-L3-QQ$e@L4i!wlhBwolTvDB_$Cu*SIY=S2P)C<#9AGQ%o?4@H7b$-1l1}NP&E*I zgQONB8W5)%GEyysV7zKb38)%~Yak~=xa#K=5|conVTeRLiUg3Q03`_&pfi<>A`w9w zM?n!GB8G$q!x8Wph(co$3BXch3W|g$fcF6MWMC0I5C=d`fGtG^R~#%Jd?hh!?!#@k zAFyood#IXWz%75) z0#70{iBvLZ1NFhYz_ExlATI1MR6K?Zv?tOa;5cAh2;9fvMMNA4LxLR;bccxGbsQet zhm8t62?xtZ#gHH{{GV;$kg!kG^cTT$z^D>%Kn%PRPoV(R$jooI@e~pU2b_U`qhiP; zAQgpxfgpf39*98!zC^)cs050L0E|T@0*@mQFu+lO7({RdVuSYxB!D6jT#2AR9#{!R z9cWDh)7EOut3@GKOdwI<0c*XED5{AFFd>16A<}S40u{JCl_dJsuiDH6e6=2+FRJ#r zvbO)9{c60Jm@CGUt8oSZ&@f~gfr%%RFn9<62no?IR2WDp&2NW!59 zLXrS90kHyb0=^DVB*8C1cwlYN2JqqVV6bYd5JUuU9|E)hqfaAHn0PqOLBLiUmkjRH zU}M8=02d73pKS_;LZd1PKu7?a2oIbRA_EjizzYEF;K^iQZ31ZHFeJba-;^Q{L4R1- zS|te};vpPGS)(kj_DTRKRBHp<71oZ(#6vU;umuo+2;2+^gePLCKtb3aXs{iDcj94t z1A%BXnvy^Q(FDM&y$$042N9kK_zDlAfJ^|v2IPR{0nSVSAd_e~5rF_02w0a0rUmx^ zKCpKJcK~h!;Q+XRBZx390&C;I74~u(`CA)U7s3G?X*K=vBEWG#I?xSB4IBb!0pKNr zZh*lkfOT;+;JpNxHUR;GVg9ByfT23*NHy-r!~-&>z_XLUuz*HkXT3Oa*2EnTrGh9tV;P0bqgyrhuQ*2*3k?b|k=uB!C$Xf_aMo2n9%8 z?MPHW7$6dW=Tu`4_=^a}p#aQ5Vgeoxr2WB-kZkc=0$8g1~x! zfT=jx1;NBHUVs6CiK=k}j0hqFggQ*bwfA8l;N(jLkpoz;I%H`$xD7%Dc5xsO1QQzY zSpuA$!MuR1L`2wo;KBk}9XJ;e1Sm+*pj~TSKu;7fOm!RN0~k*VobuuRB$9|o1?d9< zY5=hTc~`4Jg^3%aK`Q7^0t8Ni?F5(wHXf=;WiF8?qE_&>|K5=EWGb-q+?Z9l_3i&m%2a`50C{PJRRZRg@; zrkbA|)it5sXVeCa21C$dbd?1F!ICo|QU;zVhoJ7&|K>XVZc`wr^%?1Aqbsn);L