diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index 3f9a9bb..c310b75 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -107,6 +107,11 @@ public class GcAppSettings { @JdbcTypeCode(SqlTypes.JSON) private String chartLegendOptionsJson; + /** 차트 pane 구분선 옵션 JSON (visible, color, width, lineStyle) */ + @Column(name = "chart_pane_separator_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String chartPaneSeparatorJson; + /** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */ @Column(name = "trade_alert_popup", nullable = false) @Builder.Default diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index 3715f5b..e1f490b 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -95,6 +95,7 @@ public class AppSettingsService { if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight( Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString())); if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); + if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator"))); if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( Boolean.parseBoolean(d.get("tradeAlertPopup").toString())); if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled( @@ -192,6 +193,7 @@ public class AppSettingsService { m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true); m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true); m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); + m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson())); m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true); m.put("tradeAlertSound", s.getTradeAlertSound() != null ? s.getTradeAlertSound() : "bell"); diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorService.java b/backend/src/main/java/com/goldenchart/service/IndicatorService.java index dbe91f6..da3ee44 100644 --- a/backend/src/main/java/com/goldenchart/service/IndicatorService.java +++ b/backend/src/main/java/com/goldenchart/service/IndicatorService.java @@ -588,13 +588,13 @@ public class IndicatorService { int conv = intP(p, "conversionPeriods", 9); int base = intP(p, "basePeriods", 26); int span2 = intP(p, "laggingSpan2Periods", 52); - int disp = intP(p, "displacement", 26); + int senkouDisp = intP(p, "senkouDisplacement", intP(p, "displacement", 26)); + int chikouDisp = intP(p, "chikouDisplacement", intP(p, "displacement", 26)); IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv); IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(s, base); - IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, disp); - // 선행스팬B displacement 도 spanA·chikou 와 동일한 disp 를 적용해야 한다 - IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, disp); - IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, disp); + IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, senkouDisp); + IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, senkouDisp); + IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, chikouDisp); return Map.of( "plot0", toPlot(s, tenkan), "plot1", toPlot(s, kijun), diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java index cf26bca..5b38a23 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java @@ -58,6 +58,7 @@ public class LiveStrategyTimeframeService { if (candleType == null || candleType.isBlank()) return "1m"; String c = candleType.trim().toLowerCase(); if ("1d".equals(c)) return "1d"; + if ("1w".equals(c)) return "1w"; if ("1h".equals(c)) return "1h"; if ("4h".equals(c)) return "4h"; if (ALLOWED.contains(c)) return c; @@ -69,7 +70,8 @@ public class LiveStrategyTimeframeService { case "1m", "3m", "5m", "10m", "15m", "30m" -> chartTf; case "1h", "4h" -> chartTf; case "1D" -> "1d"; - case "1W", "1M" -> "1d"; + case "1W" -> "1w"; + case "1M" -> "1d"; default -> null; }; } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 5a1b57d..984d10a 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -148,7 +148,7 @@ public class StrategyConditionTimeframeService { String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { - out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"))); + addTimeframeCandleTypes(node, out); return true; } @@ -157,7 +157,7 @@ public class StrategyConditionTimeframeService { if (children.isArray() && !children.isEmpty()) { if (allTimeframeChildren(children)) { for (JsonNode tf : children) { - out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m"))); + addTimeframeCandleTypes(tf, out); } return true; } @@ -183,4 +183,17 @@ public class StrategyConditionTimeframeService { } return true; } + + /** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType */ + static void addTimeframeCandleTypes(JsonNode timeframeNode, Set out) { + JsonNode arr = timeframeNode.path("candleTypes"); + if (arr.isArray() && !arr.isEmpty()) { + for (JsonNode t : arr) { + String ct = LiveStrategyTimeframeService.normalize(t.asText("")); + if (!ct.isBlank()) out.add(ct); + } + return; + } + out.add(LiveStrategyTimeframeService.normalize(timeframeNode.path("candleType").asText("1m"))); + } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 9f85e2c..28a965e 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -39,6 +39,10 @@ public class StrategyDslTimeframeNormalizer { if (root == null || root.isNull()) return root; if ("TIMEFRAME".equals(root.path("type").asText(""))) { + // START 다중 분봉(candleTypes) — 편집기 저장값 유지 + if (hasMultipleCandleTypes(root)) { + return root; + } String expected = inferPrimaryCandleType(root, strategyName); String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m")); if (!expected.equals(current)) { @@ -145,7 +149,7 @@ public class StrategyDslTimeframeNormalizer { String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { - out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"))); + StrategyConditionTimeframeService.addTimeframeCandleTypes(node, out); } if ("CONDITION".equals(type)) { @@ -193,6 +197,11 @@ public class StrategyDslTimeframeNormalizer { return "1m"; } + private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) { + JsonNode arr = timeframeNode.path("candleTypes"); + return arr.isArray() && arr.size() > 1; + } + private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { ObjectNode copy = tf.deepCopy(); copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType)); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index e6e9ff5..febb21e 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -749,17 +749,24 @@ public class StrategyDslToTa4jAdapter { private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map p) { return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26)); } + private int ichimokuSenkouDisplacement(Map p) { + return intP(p, "senkouDisplacement", intP(p, "displacement", 26)); + } + + private int ichimokuChikouDisplacement(Map p) { + return intP(p, "chikouDisplacement", intP(p, "displacement", 26)); + } + private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map p) { return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p), - intP(p, "displacement", 26)); + ichimokuSenkouDisplacement(p)); } private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map p) { - // IndicatorService 와 동일하게 displacement 파라미터 반영 return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52), - intP(p, "displacement", 26)); + ichimokuSenkouDisplacement(p)); } private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map p) { - return new IchimokuChikouSpanIndicator(s, intP(p, "displacement", 26)); + return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p)); } /** 종가가 구름 위 */ diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index d9b17db..26b961a 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -82,9 +82,17 @@ public class StrategyTriggerBranchEvaluator { String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { - String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")); + List types = readTimeframeCandleTypes(node); JsonNode sub = firstChild(node); - return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub))); + if (types.size() <= 1) { + String ct = types.isEmpty() ? "1m" : types.get(0); + return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub))); + } + List branches = new ArrayList<>(); + for (String ct : types) { + branches.add(new BranchDef(ct, sub)); + } + return new BranchScope("OR", branches); } if ("AND".equals(type) || "OR".equals(type)) { @@ -92,9 +100,10 @@ public class StrategyTriggerBranchEvaluator { if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) { List branches = new ArrayList<>(); for (JsonNode child : children) { - String ct = LiveStrategyTimeframeService.normalize( - child.path("candleType").asText("1m")); - branches.add(new BranchDef(ct, firstChild(child))); + JsonNode sub = firstChild(child); + for (String ct : readTimeframeCandleTypes(child)) { + branches.add(new BranchDef(ct, sub)); + } } return new BranchScope(type, branches); } @@ -102,9 +111,18 @@ public class StrategyTriggerBranchEvaluator { JsonNode wrapped = findTimeframeWrapper(node); if (wrapped != null) { - String ct = LiveStrategyTimeframeService.normalize(wrapped.path("candleType").asText("1m")); + List types = readTimeframeCandleTypes(wrapped); JsonNode sub = firstChild(wrapped); - return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node))); + JsonNode subtree = sub != null && !sub.isNull() ? sub : node; + if (types.size() <= 1) { + String ct = types.isEmpty() ? "1m" : types.get(0); + return new BranchScope("SINGLE", List.of(new BranchDef(ct, subtree))); + } + List branches = new ArrayList<>(); + for (String ct : types) { + branches.add(new BranchDef(ct, subtree)); + } + return new BranchScope("OR", branches); } String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node); @@ -143,6 +161,21 @@ public class StrategyTriggerBranchEvaluator { return true; } + /** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */ + private static List readTimeframeCandleTypes(JsonNode timeframeNode) { + JsonNode arr = timeframeNode.path("candleTypes"); + if (arr.isArray() && !arr.isEmpty()) { + List list = new ArrayList<>(); + for (JsonNode t : arr) { + String ct = LiveStrategyTimeframeService.normalize(t.asText("")); + if (!ct.isBlank() && !list.contains(ct)) list.add(ct); + } + if (!list.isEmpty()) return list; + } + return List.of(LiveStrategyTimeframeService.normalize( + timeframeNode.path("candleType").asText("1m"))); + } + private final class TriggerBranchRule implements Rule { private final BranchScope scope; private final String market; diff --git a/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java index 3174911..ba95106 100644 --- a/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java +++ b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java @@ -25,7 +25,7 @@ import java.util.concurrent.ConcurrentHashMap; * * 구조: ConcurrentHashMap> * - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드 - * - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d" + * - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d" * * 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용. * 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸. @@ -56,6 +56,7 @@ public class Ta4jStorage { "30m", "minutes/30", "1h", "minutes/60", "4h", "minutes/240", + "1w", "weeks", "1d", "days" ); @@ -237,6 +238,7 @@ public class Ta4jStorage { case "30m" -> Duration.ofMinutes(30); case "1h" -> Duration.ofHours(1); case "4h" -> Duration.ofHours(4); + case "1w" -> Duration.ofDays(7); case "1d" -> Duration.ofDays(1); default -> Duration.ofMinutes(1); }; diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java index 67e7e68..13d0547 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java @@ -48,7 +48,7 @@ public class CandleGapBackfillService { @Value("${upbit.api.request-delay-ms:110}") private long requestDelayMs; - private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"}; + private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"}; private volatile long lastRunAtMs; private final AtomicInteger lastRunMarkets = new AtomicInteger(); diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index d22de11..53c56eb 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -32,9 +32,9 @@ import java.util.concurrent.ConcurrentHashMap; * 명세서 3.2 준수: * - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신 * - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기 - * - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파 + * - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d)으로 집계 전파 * - * 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d + * 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d */ @Component @RequiredArgsConstructor @@ -50,8 +50,8 @@ public class BarBuilder { private final StrategyConditionTimeframeService conditionTimeframes; /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */ - private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 1440}; - private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"}; + private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440}; + private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"}; /** 진행 중인 1분봉 상태 (market → PartialBar) */ private final ConcurrentHashMap partialBars = new ConcurrentHashMap<>(); diff --git a/backend/src/main/resources/db/migration/V42__chart_pane_separator_json.sql b/backend/src/main/resources/db/migration/V42__chart_pane_separator_json.sql new file mode 100644 index 0000000..40169f9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V42__chart_pane_separator_json.sql @@ -0,0 +1,7 @@ +-- ============================================================ +-- V42: gc_app_settings — 차트 pane 구분선 옵션 JSON +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN chart_pane_separator_json JSON NULL + COMMENT '차트 영역(캔들·거래량·보조지표 pane) 구분선: visible,color,width,lineStyle'; diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index 84c305f..259a22b 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -55,6 +55,28 @@ class StrategyConditionTimeframeServiceTest { assertFalse(service.usesTimeframe(1L, "1m")); } + @Test + void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() { + GcStrategy multi = new GcStrategy(); + multi.setId(3L); + multi.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """); + when(strategyRepo.findById(3L)).thenReturn(Optional.of(multi)); + + Set tfs = service.collectForStrategy(3L); + assertEquals(Set.of("1m", "3m", "5m"), tfs); + assertTrue(service.usesTimeframe(3L, "3m")); + } + @Test void collectForStrategy_legacyTreeDefaultsTo1m() { GcStrategy legacy = new GcStrategy(); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java new file mode 100644 index 0000000..2c6e90b --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -0,0 +1,35 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StrategyDslTimeframeNormalizerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper); + + @Test + void normalize_preservesMultiCandleTypesOnSave() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """; + String normalized = normalizer.normalizeJson(json, "5분봉 테스트 전략"); + JsonNode root = objectMapper.readTree(normalized); + assertTrue(root.path("candleTypes").isArray()); + assertEquals(3, root.path("candleTypes").size()); + assertEquals("1m", root.path("candleTypes").get(0).asText()); + assertEquals("3m", root.path("candleTypes").get(1).asText()); + assertEquals("5m", root.path("candleTypes").get(2).asText()); + } +} diff --git a/backend/src/test/java/com/goldenchart/service/StrategyEvaluationTimeframeIntegrationTest.java b/backend/src/test/java/com/goldenchart/service/StrategyEvaluationTimeframeIntegrationTest.java new file mode 100644 index 0000000..a39c908 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyEvaluationTimeframeIntegrationTest.java @@ -0,0 +1,85 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * START candleTypes → BarBuilder 게이트(usesTimeframe) → TriggerBranch 평가 연결 검증. + */ +@ExtendWith(MockitoExtension.class) +class StrategyEvaluationTimeframeIntegrationTest { + + @Mock + private GcStrategyRepository strategyRepo; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private StrategyDslTimeframeNormalizer dslNormalizer; + + @Mock + private LiveStrategyEvaluator liveStrategyEvaluator; + + private StrategyConditionTimeframeService timeframeService; + + private StrategyTriggerBranchEvaluator triggerEvaluator; + + @BeforeEach + void setUp() { + timeframeService = new StrategyConditionTimeframeService( + strategyRepo, objectMapper, dslNormalizer, liveStrategyEvaluator); + triggerEvaluator = new StrategyTriggerBranchEvaluator( + new StrategyDslToTa4jAdapter(), objectMapper, new StrategyBranchStateCache()); + } + + @Test + void checkedTimeframes_gateBarCloseAndTriggerScope() throws Exception { + String buyJson = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }] + } + """; + GcStrategy strategy = new GcStrategy(); + strategy.setId(100L); + strategy.setName("multi tf test"); + strategy.setBuyConditionJson(buyJson); + when(strategyRepo.findById(100L)).thenReturn(Optional.of(strategy)); + when(dslNormalizer.normalizeJson(buyJson, strategy.getName())).thenReturn(buyJson); + + Set collected = timeframeService.collectForStrategy(100L); + assertEquals(Set.of("1m", "3m", "5m"), collected); + + assertTrue(timeframeService.usesTimeframe(100L, "1m")); + assertTrue(timeframeService.usesTimeframe(100L, "3m")); + assertTrue(timeframeService.usesTimeframe(100L, "5m")); + assertFalse(timeframeService.usesTimeframe(100L, "15m")); + + var scope = triggerEvaluator.parseScope(buyJson); + assertEquals("OR", scope.combineOp()); + assertEquals(3, scope.branches().size()); + assertTrue(scope.branches().stream().anyMatch(b -> "3m".equals(b.candleType()))); + } +} diff --git a/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java index 92d982b..6d68702 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java @@ -110,6 +110,52 @@ class StrategyTriggerBranchEvaluatorTest { assertTrue(rule1m.isSatisfied(idx1m, null)); } + @Test + void multiCandleTypes_sameSubtree_onlyEvaluatesTriggerBranch() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m"], + "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }] + } + """; + + var scope = evaluator.parseScope(json); + assertEquals("OR", scope.combineOp()); + assertEquals(3, scope.branches().size()); + assertEquals("1m", scope.branches().get(0).candleType()); + assertEquals("3m", scope.branches().get(1).candleType()); + assertEquals("5m", scope.branches().get(2).candleType()); + + Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "1m", Map.of(), storage); + BarSeries s1m = storage.getOrCreate(MARKET, "1m"); + on1m.isSatisfied(s1m.getEndIndex(), null); + assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m")); + assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"), + "1m 마감 트리거 시 3m 분기는 평가·캐시되지 않아야 함"); + assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "5m")); + + branchCache.invalidateStrategy(STRATEGY_ID); + Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "3m", Map.of(), storage); + BarSeries s3m = storage.getOrCreate(MARKET, "3m"); + on3m.isSatisfied(s3m.getEndIndex(), null); + assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m")); + assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m")); + + Rule on5mWrongTrigger = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "1m", Map.of(), storage); + assertFalse(on5mWrongTrigger.isSatisfied(s3m.getEndIndex(), null), + "3m 인덱스로 1m 트리거 Rule 평가 시 false"); + } + @Test void single3mScope_onlyTriggersOn3mBranch() throws Exception { String json = """ diff --git a/frontend/src/App.css b/frontend/src/App.css index 3561c16..c7e7d58 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2797,6 +2797,34 @@ html.theme-blue { color: var(--text2); flex-shrink: 0; } +.ism-hist-colors { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.ism-hist-color-field { + display: flex; + align-items: center; + gap: 4px; +} +.ism-macd-hist-style-block { + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--sep); +} +.ism-macd-hist-style-title { + margin-bottom: 2px; +} +.ism-plot-title--sub { + font-size: 11px; + font-weight: 600; + color: var(--text2); +} +.ism-style-field--wide { + flex: 1 1 auto; + min-width: 0; +} /* 색상 입력 */ .ism-color-wrap { display: flex; align-items: center; gap: 4px; position: relative; } .ism-color-swatch-btn { @@ -3012,9 +3040,24 @@ html.theme-blue { border-radius: 8px; background: var(--bg2); } -.ism-ichimoku-cloud-card-label { - display: block; +.ism-ichimoku-cloud-card--off { + opacity: 0.72; +} +.ism-ichimoku-cloud-card--off .ism-ichimoku-cloud-card-row, +.ism-ichimoku-cloud-card--off .ism-ichimoku-cloud-meta { + opacity: 0.45; + pointer-events: none; +} +.ism-ichimoku-cloud-card-head { + display: flex; + align-items: center; + gap: 8px; margin-bottom: 8px; +} +.ism-ichimoku-cloud-toggle { + flex-shrink: 0; +} +.ism-ichimoku-cloud-card-label { font-size: 12px; font-weight: 600; color: var(--text); @@ -9652,7 +9695,7 @@ html.theme-blue { min-height: 0; display: flex; flex-direction: column; - justify-content: space-evenly; + justify-content: flex-start; gap: clamp(4px, 1.2cqi, 10px); overflow-y: auto; overflow-x: hidden; @@ -9709,13 +9752,28 @@ html.theme-blue { } .rsp-trade-card-body .top-price-qty-block { - flex: 1 1 auto; + flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; - justify-content: space-evenly; + justify-content: flex-start; gap: clamp(4px, 1cqi, 8px); } + +.rsp-trade-card-body .top-pct-row--block { + flex-wrap: wrap; + row-gap: 4px; +} + +.rsp-trade-card-body .top-pct-row--block .top-pct-btn { + flex: 1 1 calc(20% - 4px); + min-width: 2.4rem; +} + +.rsp-trade-card-body .top-pct-row--block .top-pct-btn--direct { + flex: 1 1 calc(33% - 4px); + min-width: 3.2rem; +} /* 가격·수량: 기본 세로 배치 */ .top-price-qty-block { display: flex; @@ -9972,11 +10030,14 @@ html.theme-blue { } .top-pct-row { display: flex; + flex-wrap: wrap; gap: 4px; + row-gap: 4px; margin-top: 6px; } .top-pct-btn { - flex: 1; + flex: 1 1 calc(20% - 4px); + min-width: 2.4rem; padding: 5px 2px; font-size: 10px; border: 1px solid var(--border); @@ -9986,7 +10047,8 @@ html.theme-blue { cursor: pointer; } .top-pct-btn--direct { - flex: 1.3; + flex: 1 1 calc(33% - 4px); + min-width: 3.2rem; } .top-pct-btn--active { border-color: var(--accent); @@ -11289,6 +11351,29 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .tnl-page--with-right .ptd-ob-stack--fill { height: 100%; } + +/* 매매 시그널 알림 우측: 좁은 카드에서도 주문 비율·총액이 겹치지 않도록 */ +.tnl-page--with-right .ptd-split-panel--right .ptd-order-card > .top-panel { + justify-content: flex-start; +} +.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-panel-fields { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + justify-content: flex-start; +} +.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-price-qty-block { + flex: 0 0 auto; + justify-content: flex-start; +} +.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-actions { + margin-top: auto; +} +@container trade-card (max-height: 400px) { + .tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-meta { + font-size: 9px; + } +} .tnl-row--selected { border-color: var(--accent, #7aa2f7); box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b527377..4092fea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -596,6 +596,7 @@ function App() { const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; const chartLegendOptions = appDefaults.chartLegendOptions; + const chartPaneSeparator = appDefaults.chartPaneSeparator; const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; const [paperCashBalance, setPaperCashBalance] = useState(0); @@ -970,6 +971,12 @@ function App() { slotRefs.current.forEach(slot => slot?.setVolumeVisible(chartVolumeVisible)); }, [appSettingsLoaded, chartVolumeVisible]); + useEffect(() => { + if (!appSettingsLoaded) return; + managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); + slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator)); + }, [appSettingsLoaded, chartPaneSeparator]); + // mainChartStyle 변경 → ChartManager 즉시 적용 useEffect(() => { managerRef.current?.updateMainSeriesStyle(mainChartStyle); @@ -1791,6 +1798,12 @@ function App() { chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record, }); }} + chartPaneSeparator={chartPaneSeparator} + onChartPaneSeparatorChange={opts => { + saveAppDef({ chartPaneSeparator: opts }); + managerRef.current?.setPaneSeparatorOptions(opts); + slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts)); + }} paperTradingEnabled={paperTradingEnabled} onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })} paperInitialCapital={appDefaults.paperInitialCapital} @@ -2096,6 +2109,7 @@ function App() { onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} + chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2134,6 +2148,7 @@ function App() { onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} + chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2204,6 +2219,7 @@ function App() { mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); + mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setDisplayTimezone(displayTimezone, timeframe); syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 @@ -2243,6 +2259,8 @@ function App() { onDuplicateIndicator={handleDuplicateIndicator} focusedIndicatorId={focusedIndicatorId} onRestoreIndicators={handleRestoreIndicators} + volumeVisible={chartVolumeVisible} + paneSeparatorOptions={chartPaneSeparator} /> {/* 백테스팅 결과 통계 배지 */} diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 8725f57..05fc7b3 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -42,6 +42,7 @@ import type { IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, } from '../types'; import type { ChartManager } from '../utils/ChartManager'; +import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; // ── 타임프레임 옵션 ──────────────────────────────────────────────────────── const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M']; @@ -112,6 +113,7 @@ export interface ChartSlotHandle { setSeriesPriceLabelsEnabled: (enabled: boolean) => void; /** 거래량 pane on/off */ setVolumeVisible: (visible: boolean) => void; + setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void; } // ── Props ───────────────────────────────────────────────────────────────── @@ -172,6 +174,8 @@ export interface ChartSlotProps { chartLiveReceiveHighlight?: boolean; /** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */ chartVisible?: boolean; + /** pane 구분선 */ + chartPaneSeparator?: ChartPaneSeparatorOptions; } const ChartSlot = forwardRef(function ChartSlot({ @@ -195,6 +199,7 @@ const ChartSlot = forwardRef(function ChartSlot compactMode = false, chartLiveReceiveHighlight = true, chartVisible = true, + chartPaneSeparator, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); @@ -309,6 +314,9 @@ const ChartSlot = forwardRef(function ChartSlot setVolumeVisible: (visible: boolean) => { managerRef.current?.setVolumeVisible(visible); }, + setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => { + managerRef.current?.setPaneSeparatorOptions(opts); + }, }), []); // 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영 @@ -324,6 +332,11 @@ const ChartSlot = forwardRef(function ChartSlot managerRef.current?.setVolumeVisible(chartVolumeVisible); }, [chartVolumeVisible]); + useEffect(() => { + if (!chartPaneSeparator) return; + managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); + }, [chartPaneSeparator]); + // 외부 심볼 동기화 useEffect(() => { if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol); @@ -806,6 +819,7 @@ const ChartSlot = forwardRef(function ChartSlot drawingsVisible={!compactMode} showHoverToolbar={!compactMode} volumeVisible={chartVolumeVisible} + paneSeparatorOptions={chartPaneSeparator} onCrosshair={data => { setLegend(data); if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0); @@ -824,6 +838,7 @@ const ChartSlot = forwardRef(function ChartSlot } mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); mgr.setVolumeVisible(chartVolumeVisible); + if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator); // Time sync: 가시 시간 범위 변화 구독 mgr.subscribeVisibleTimeRange(r => { if (!r || isSyncingTimeRef.current) return; diff --git a/frontend/src/components/ColorInput.tsx b/frontend/src/components/ColorInput.tsx index 8017372..c574f1e 100644 --- a/frontend/src/components/ColorInput.tsx +++ b/frontend/src/components/ColorInput.tsx @@ -11,9 +11,10 @@ import ColorPickerPanel from './ColorPickerPanel'; export interface ColorInputProps { value: string; onChange: (v: string) => void; + disabled?: boolean; } -const ColorInput: React.FC = ({ value, onChange }) => { +const ColorInput: React.FC = ({ value, onChange, disabled = false }) => { const { hex6, alpha } = parsePlotColor(value); const [open, setOpen] = useState(false); const anchorRef = useRef(null); @@ -88,13 +89,15 @@ const ColorInput: React.FC = ({ value, onChange }) => { ) : null; return ( -
+
)} {!inputsOnly && ( - isHistogram ? ( + isHistogram && indicatorType === 'MACD' ? ( +
+
+ 상승 + onPlotStyle(plotIndex, { histogramUpColor: v, color: v })} + /> +
+
+ 하락 + onPlotStyle(plotIndex, { histogramDownColor: v })} + /> +
+
+ ) : isHistogram ? ( onPlotStyle(plotIndex, { color: v })} /> ) : ( @@ -339,45 +360,72 @@ export const SettingsOutputFooter: React.FC<{ export const IchimokuCloudSection: React.FC<{ cloudColors: IchimokuCloudColors; onChange: (patch: Partial) => void; -}> = ({ cloudColors, onChange }) => ( - <> -
-
-

구름 영역 색상

-

- 선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름이 표시됩니다. -

-
-
- 상승 구름 (선행1 > 선행2) -
- onChange({ bullishColor: v })} - /> +}> = ({ cloudColors, onChange }) => { + const bullishOn = cloudColors.bullishVisible !== false; + const bearishOn = cloudColors.bearishVisible !== false; + + return ( + <> +
+
+

구름 영역

+

+ 선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름을 그릴 수 있습니다. 상승·하락 구름은 각각 on/off 할 수 있습니다. +

+
+
+
+ + 상승 구름 (선행1 > 선행2) +
+
+ onChange({ bullishColor: v })} + /> +
+
+ {colorToRgbaString(cloudColors.bullishColor)} + {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}% +
-
- {colorToRgbaString(cloudColors.bullishColor)} - {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}% -
-
-
- 하락 구름 (선행1 < 선행2) -
- onChange({ bearishColor: v })} - /> -
-
- {colorToRgbaString(cloudColors.bearishColor)} - {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}% +
+
+ + 하락 구름 (선행1 < 선행2) +
+
+ onChange({ bearishColor: v })} + /> +
+
+ {colorToRgbaString(cloudColors.bearishColor)} + {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}% +
-
- -); + + ); +}; /* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */ export const BollingerBackgroundRow: React.FC<{ diff --git a/frontend/src/components/IndicatorSettingsStyleSection.tsx b/frontend/src/components/IndicatorSettingsStyleSection.tsx index 2e63171..397c5bf 100644 --- a/frontend/src/components/IndicatorSettingsStyleSection.tsx +++ b/frontend/src/components/IndicatorSettingsStyleSection.tsx @@ -9,7 +9,12 @@ import { getPlotLabel } from '../utils/indicatorLabels'; import { getHlinePriceSpec } from '../utils/indicatorParamSpec'; import NumericParamInput from './NumericParamInput'; import { ColorInput } from './IndicatorSettingsSections'; -import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils'; +import { + LINE_WIDTH_OPTIONS, + LINE_STYLE_OPTIONS, + resolveHistogramUpColor, + resolveHistogramDownColor, +} from '../utils/plotColorUtils'; import { IchimokuCloudSection } from './IndicatorSettingsSections'; import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig'; @@ -199,12 +204,46 @@ const IndicatorSettingsStyleSection: React.FC {plots.map((plot, idx) => ( - onPlotStyle(idx, patch)} - /> + indicatorType === 'MACD' && plot.type === 'histogram' ? ( +
+
+
+ {plotLabel(plot)} +
+
+
+
+ 상승 막대 +
+
+ 색상 + onPlotStyle(idx, { histogramUpColor: v, color: v })} + /> +
+
+
+
+ 하락 막대 +
+
+ 색상 + onPlotStyle(idx, { histogramDownColor: v })} + /> +
+
+
+ ) : ( + onPlotStyle(idx, patch)} + /> + ) ))} {hlines.length > 0 && ( diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index 2a51181..f173d6e 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -252,12 +252,51 @@ function assignLayoutsByPaneIndex( const lay = byPane.get(host.paneIndex); if (!lay || lay.height < MIN_SUB_PANE_HEIGHT) continue; for (const item of slot.items) { + if (item.layoutTopY != null) continue; item.layoutTopY = lay.topY; item.layoutHeight = lay.height; } } } +/** 화면 순서(위→아래)의 활성 sub-pane ↔ 슬롯 순서 — orphan pane index 와 분리 */ +function assignLayoutsBySubPaneOrder( + slots: VisualSlot[], + subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, +): void { + for (let i = 0; i < slots.length && i < subLayouts.length; i++) { + const lay = subLayouts[i]; + for (const item of slots[i].items) { + if (item.layoutTopY != null) continue; + item.layoutTopY = lay.topY; + item.layoutHeight = lay.height; + } + } +} + +/** paneIndex·orphan pane 으로 어긋난 좌표를 슬롯 순서 기준 sub-pane 위치로 보정 */ +function reconcileLayoutsWithSubPanes( + slots: VisualSlot[], + subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, +): void { + const TOL = 24; + for (let i = 0; i < slots.length && i < subLayouts.length; i++) { + const expected = subLayouts[i]; + for (const item of slots[i].items) { + const cur = item.layoutTopY; + const curH = item.layoutHeight; + if ( + cur == null + || Math.abs(cur - expected.topY) > TOL + || (curH != null && Math.abs(curH - expected.height) > TOL) + ) { + item.layoutTopY = expected.topY; + item.layoutHeight = expected.height; + } + } + } +} + function buildPaneItems( manager: ChartManager, containerEl: HTMLElement | null, @@ -301,7 +340,12 @@ function buildPaneItems( } const slots = buildVisualSlots(items, indicators); + const subLayouts = manager.getIndicatorSubPaneLayouts(); + assignLayoutsBySubPaneOrder(slots, subLayouts); assignLayoutsByPaneIndex(slots, manager.getPaneLayouts()); + if (subLayouts.length > 0) { + reconcileLayoutsWithSubPanes(slots, subLayouts); + } if (containerEl?.isConnected) { const missing = items.filter(i => i.layoutTopY == null); @@ -319,7 +363,7 @@ function buildPaneItems( } } - return items.filter(i => i.layoutTopY != null && i.layoutHeight != null); + return items; } function paneItemLayout( @@ -457,10 +501,18 @@ const PaneLegend: React.FC = ({ setTimeout(syncPaneItems, 900), setTimeout(syncPaneItems, 1200), setTimeout(syncPaneItems, 1800), + setTimeout(syncPaneItems, 2500), ]; + requestAnimationFrame(() => { + requestAnimationFrame(syncPaneItems); + }); }, [syncPaneItems]); - useEffect(() => { scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); }, [scheduleRetry]); + useEffect(() => { + setPaneItems([]); + scheduleRetry(); + return () => retryRef.current.forEach(clearTimeout); + }, [scheduleRetry]); useEffect(() => { const unsub = manager.subscribePaneLayout(() => scheduleRetry()); diff --git a/frontend/src/components/PaneSeparatorOverlay.tsx b/frontend/src/components/PaneSeparatorOverlay.tsx new file mode 100644 index 0000000..42a4c02 --- /dev/null +++ b/frontend/src/components/PaneSeparatorOverlay.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import type { ChartManager } from '../utils/ChartManager'; +import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import { plotColorCss } from '../utils/plotColorUtils'; + +interface Props { + manager: ChartManager; + options: ChartPaneSeparatorOptions; +} + +function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) { + if (style === 'dashed') ctx.setLineDash([6, 4]); + else if (style === 'dotted') ctx.setLineDash([2, 3]); + else ctx.setLineDash([]); +} + +const PaneSeparatorOverlay: React.FC = ({ manager, options }) => { + const canvasRef = useRef(null); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const container = manager.getContainer(); + if (!canvas || !container) return; + + const cssW = container.clientWidth; + const cssH = container.clientHeight; + if (cssW <= 0 || cssH <= 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + if (!options.visible) return; + + const layouts = manager.getPaneLayouts() + .filter(l => l.height > 8) + .sort((a, b) => a.topY - b.topY); + if (layouts.length < 2) return; + + ctx.strokeStyle = plotColorCss(options.color); + ctx.lineWidth = options.width; + applyLineDash(ctx, options.lineStyle); + + for (let i = 0; i < layouts.length - 1; i++) { + const y = layouts[i].topY + layouts[i].height; + const lineY = y + options.width / 2; + ctx.beginPath(); + ctx.moveTo(0, lineY); + ctx.lineTo(cssW, lineY); + ctx.stroke(); + } + }, [manager, options]); + + useEffect(() => { + redraw(); + const unsub = manager.subscribePaneLayout(redraw); + const container = manager.getContainer(); + const ro = new ResizeObserver(() => redraw()); + if (container) ro.observe(container); + return () => { + unsub(); + ro.disconnect(); + }; + }, [manager, redraw]); + + return ( + + ); +}; + +export default PaneSeparatorOverlay; diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 7ff64c1..b87b193 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -35,6 +35,12 @@ import { CHART_LEGEND_SETTING_ITEMS, type ChartLegendVisibility, } from '../types/chartLegend'; +import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import { + LINE_STYLE_LABELS, + LINE_STYLE_OPTIONS, + LINE_WIDTH_OPTIONS, +} from '../utils/plotColorUtils'; import TimezonePicker from './TimezonePicker'; import AdminPasswordGate from './AdminPasswordGate'; import AdminSettingsPanel from './AdminSettingsPanel'; @@ -108,6 +114,8 @@ interface SettingsPageProps { onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; + chartPaneSeparator?: ChartPaneSeparatorOptions; + onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; paperTradingEnabled?: boolean; onPaperTradingEnabled?: (v: boolean) => void; paperInitialCapital?: number; @@ -638,6 +646,8 @@ interface ChartPanelProps { onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; + chartPaneSeparator?: ChartPaneSeparatorOptions; + onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; } const ChartPanel: React.FC = ({ @@ -652,6 +662,8 @@ const ChartPanel: React.FC = ({ onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, + chartPaneSeparator, + onChartPaneSeparatorChange, }) => { const [upColor, setUp] = useState('#26a69a'); const [downColor, setDown] = useState('#ef5350'); @@ -758,6 +770,75 @@ const ChartPanel: React.FC = ({ + {chartPaneSeparator && onChartPaneSeparatorChange && ( + + + + + {chartPaneSeparator.visible && ( + <> + +
+ onChartPaneSeparatorChange({ + ...chartPaneSeparator, + color: e.target.value, + })} + /> + {chartPaneSeparator.color} +
+
+ + + + + + + + )} +
+ )} + {chartLegendOptions && onChartLegendOptionsChange && ( {CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => ( @@ -1443,6 +1524,8 @@ const SettingsPage: React.FC = ({ onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, + chartPaneSeparator, + onChartPaneSeparatorChange, tradeAlertSoundEnabled = true, onTradeAlertSoundEnabled, tradeAlertSound = 'bell', @@ -1533,6 +1616,8 @@ const SettingsPage: React.FC = ({ onChartLiveReceiveHighlight={onChartLiveReceiveHighlight} chartLegendOptions={chartLegendOptions} onChartLegendOptionsChange={onChartLegendOptionsChange} + chartPaneSeparator={chartPaneSeparator} + onChartPaneSeparatorChange={onChartPaneSeparatorChange} /> ); case 'indicators': diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index d4d5be2..a32401f 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -363,7 +363,7 @@ export default function StrategyEditorPage({ theme }: Props) { schedulePersistFlowLayout(layoutStrategyKey); }, [layoutStrategyKey, schedulePersistFlowLayout]); - const handleStartMetaChange = useCallback((meta: Record) => { + const handleStartMetaChange = useCallback((meta: Record) => { setCurrentLayout(prev => ({ ...prev, startMeta: meta })); schedulePersistFlowLayout(layoutStrategyKey); }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 794a22b..96c1143 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -51,6 +51,8 @@ import CandlePaneControls from './CandlePaneControls'; import { getKoreanName } from '../utils/marketNameCache'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; +import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import PaneSeparatorOverlay from './PaneSeparatorOverlay'; interface TradingChartProps { bars: OHLCVBar[]; @@ -126,6 +128,8 @@ interface TradingChartProps { seriesPriceLabelsEnabled?: boolean; /** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */ chartVisible?: boolean; + /** pane(캔들·거래량·보조지표) 구분선 */ + paneSeparatorOptions?: ChartPaneSeparatorOptions; } const TradingChart: React.FC = ({ @@ -156,6 +160,7 @@ const TradingChart: React.FC = ({ showHoverToolbar = true, seriesPriceLabelsEnabled = true, chartVisible = true, + paneSeparatorOptions, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -207,6 +212,7 @@ const TradingChart: React.FC = ({ const prevSortedPKRef = useRef(''); // 순서 무관 paramKey (reorder 감지용) const prevIndicatorsListRef = useRef(indicators); const indicatorSyncInFlightRef = useRef(false); + const indicatorReloadGenRef = useRef(0); const prevMarket = useRef(market); const prevMarketTf = useRef(timeframe); const lastAppliedMarketRef = useRef(''); @@ -281,6 +287,11 @@ const TradingChart: React.FC = ({ managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled); }, [seriesPriceLabelsEnabled]); + useEffect(() => { + if (!paneSeparatorOptions) return; + managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions); + }, [paneSeparatorOptions]); + /** * pane 높이 재배분 + 스크롤 컨테이너 확장 * @@ -409,18 +420,27 @@ const TradingChart: React.FC = ({ ) => { const cover = createIndicatorReloadCover(containerRef.current); const savedLR = mgr.getVisibleLogicalRange(); + const reloadGen = ++indicatorReloadGenRef.current; indicatorSyncInFlightRef.current = true; void mgr.reloadIndicatorsOnly(inds).then(() => { + if (reloadGen !== indicatorReloadGenRef.current) { + fadeOutIndicatorReloadCover(cover); + return; + } afterIndicatorPaneMutation(mgr); requestAnimationFrame(() => requestAnimationFrame(() => { + if (reloadGen !== indicatorReloadGenRef.current) { + fadeOutIndicatorReloadCover(cover); + return; + } restoreLogicalRange(mgr, savedLR); fadeOutIndicatorReloadCover(cover); indicatorSyncInFlightRef.current = false; onComplete?.(); })); }); - }, [applyPaneLayout, afterIndicatorPaneMutation, restoreLogicalRange]); + }, [afterIndicatorPaneMutation, restoreLogicalRange]); const queueFullReload = useCallback(() => { if (!chartVisibleRef.current) return; @@ -635,6 +655,7 @@ const TradingChart: React.FC = ({ mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined); } mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled); + if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions); onManagerReady(mgr); const unsub = mgr.subscribeCrosshair((p: MouseEventParams
diff --git a/frontend/src/components/trendSearch/TrendSearchResultCard.tsx b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx index 89c5c97..8d5e749 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultCard.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx @@ -22,6 +22,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; ticker?: TickerData; updatedAt?: number; flash?: boolean; @@ -58,6 +59,7 @@ const TrendSearchResultCard: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, ticker, updatedAt, flash = false, @@ -139,6 +141,7 @@ const TrendSearchResultCard: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} /> ) : ( ; lastUpdatedAt?: number; isInTargets?: (market: string) => boolean; @@ -37,6 +38,7 @@ const TrendSearchResultsCardGrid: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, tickers, lastUpdatedAt, isInTargets, @@ -84,6 +86,7 @@ const TrendSearchResultsCardGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} ticker={tickers?.get(row.market)} updatedAt={lastUpdatedAt} flash={flashMarkets?.has(row.market)} diff --git a/frontend/src/components/verificationBoard/VerificationIssueToastStack.tsx b/frontend/src/components/verificationBoard/VerificationIssueToastStack.tsx index d77c904..63cf15d 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueToastStack.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueToastStack.tsx @@ -43,7 +43,7 @@ const VerificationIssueToastStack: React.FC = ({ onGoToIssue }) => {

{headline(item.eventType, item.stage, item.previousStage)}

-

{item.title}

+

{item.title}

diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 4be4103..7e87f12 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -42,6 +42,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; chartLiveReceiveHighlight?: boolean; ticker?: TickerData; } @@ -68,6 +69,7 @@ const VirtualTargetCard: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, chartLiveReceiveHighlight = true, ticker, }) => { @@ -174,6 +176,7 @@ const VirtualTargetCard: React.FC = ({ running={running} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} chartTimeframe={chartTimeframe} /> ) : ( diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index b5d913e..62fa3a8 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -31,6 +31,7 @@ interface Props { chartRealtimeSource?: ChartRealtimeSource; /** 차트 설정 — 지표 가격축 라벨·설명 */ chartSeriesPriceLabels?: boolean; + chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; /** 전체보기 분할 pane — 캔버스 높이 100% */ fillHeight?: boolean; /** 카드 푸터 평가 분봉과 동기화 */ @@ -46,6 +47,7 @@ const VirtualTargetCardChart: React.FC = ({ running = false, chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, fillHeight = false, chartTimeframe, }) => { @@ -217,6 +219,7 @@ const VirtualTargetCardChart: React.FC = ({ volumeVisible={false} showHoverToolbar={false} seriesPriceLabelsEnabled={chartSeriesPriceLabels} + paneSeparatorOptions={chartPaneSeparator} />
diff --git a/frontend/src/components/virtual/VirtualTargetCardFoot.tsx b/frontend/src/components/virtual/VirtualTargetCardFoot.tsx index b750f2b..3bc2737 100644 --- a/frontend/src/components/virtual/VirtualTargetCardFoot.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardFoot.tsx @@ -3,6 +3,11 @@ import type { StrategyDto } from '../../utils/backendApi'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { formatUpdatedTime } from '../../utils/virtualSignalMetrics'; import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes'; +import { + defaultStrategyOptionLabel, + parseTargetStrategySelectValue, + targetStrategySelectValue, +} from '../../utils/virtualTargetStrategy'; interface Props { snapshot: VirtualIndicatorSnapshot | undefined; @@ -31,15 +36,16 @@ const VirtualTargetCardFoot: React.FC = ({ diff --git a/frontend/src/components/virtual/VirtualTargetFocusView.tsx b/frontend/src/components/virtual/VirtualTargetFocusView.tsx index c800eb1..192373b 100644 --- a/frontend/src/components/virtual/VirtualTargetFocusView.tsx +++ b/frontend/src/components/virtual/VirtualTargetFocusView.tsx @@ -35,6 +35,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; chartLiveReceiveHighlight?: boolean; ticker?: TickerData; } @@ -58,6 +59,7 @@ const VirtualTargetFocusView: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, chartLiveReceiveHighlight = true, ticker, }) => { @@ -114,6 +116,7 @@ const VirtualTargetFocusView: React.FC = ({ running={running} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} fillHeight chartTimeframe={chartTimeframe} /> diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index f38defe..285779d 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -13,6 +13,7 @@ import { import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { TickerData } from '../../hooks/useMarketTicker'; +import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy'; interface Props { targets: VirtualTargetItem[]; @@ -28,6 +29,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; chartLiveReceiveHighlight?: boolean; tickers?: Map; viewMode: VirtualCardViewMode; @@ -44,6 +46,7 @@ const VirtualTargetGrid: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartPaneSeparator, chartLiveReceiveHighlight = true, tickers, viewMode, @@ -111,7 +114,7 @@ const VirtualTargetGrid: React.FC = ({ {focusMarket && focusTarget ? ( s.id === (focusTarget.strategyId ?? session.globalStrategyId))} + strategy={strategies.find(s => s.id === resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId))} strategies={strategies} strategyId={focusTarget.strategyId} globalStrategyId={session.globalStrategyId} @@ -127,13 +130,14 @@ const VirtualTargetGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(focusTarget.market)} /> ) : (
{targets.map(t => { - const strat = strategies.find(s => s.id === (t.strategyId ?? session.globalStrategyId)); + const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId)); return ( = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(t.market)} /> diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 57b85e2..05e0968 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -45,6 +45,10 @@ import { resolveTrendSearchAppSettings, type TrendSearchAppSettings, } from '../utils/trendSearchAppSettings'; +import { + resolveChartPaneSeparatorOptions, + type ChartPaneSeparatorOptions, +} from '../types/chartPaneSeparator'; // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 let _cache: AppSettingsDto | null = null; @@ -104,6 +108,10 @@ export function resolveAppDefaults(s: AppSettingsDto) { chartLegendOptions: resolveChartLegendOptions( s.chartLegendOptions as Partial | null | undefined, ), + chartPaneSeparator: resolveChartPaneSeparatorOptions( + s.chartPaneSeparator as Partial | null | undefined, + (s.defaultTheme ?? 'dark') as Theme, + ), tradeAlertPopup: s.tradeAlertPopup ?? true, tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true, tradeAlertSound: s.tradeAlertSound ?? 'bell', diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 42cef0f..c7e6179 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -285,13 +285,24 @@ min-height: 0; display: flex; flex-direction: column; - justify-content: space-between; - gap: clamp(3px, 1.4cqh, 10px); + justify-content: flex-start; + gap: clamp(4px, 1.2cqh, 10px); overflow: hidden; overscroll-behavior: contain; padding: 2px 2px 0; } +.ptd-split-panel--right .ptd-order-card .top-panel-fields { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: clamp(4px, 1.2cqh, 10px); + overflow-y: auto; + overflow-x: hidden; +} + .ptd-split-panel--right .ptd-order-card .top-paper-hint { flex-shrink: 0; margin: 0; @@ -313,15 +324,30 @@ } .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - flex: 1 1 0; + flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; - justify-content: space-evenly; - gap: clamp(2px, 1.2cqh, 8px); + justify-content: flex-start; + gap: clamp(4px, 1.2cqh, 8px); margin: 0; } +.ptd-split-panel--right .ptd-order-card .top-pct-row--block { + flex-wrap: wrap; + row-gap: 4px; +} + +.ptd-split-panel--right .ptd-order-card .top-pct-row--block .top-pct-btn { + flex: 1 1 calc(20% - 4px); + min-width: 2.4rem; +} + +.ptd-split-panel--right .ptd-order-card .top-pct-row--block .top-pct-btn--direct { + flex: 1 1 calc(33% - 4px); + min-width: 3.2rem; +} + .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field, .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block { margin: 0; @@ -359,7 +385,7 @@ .ptd-split-panel--right .ptd-order-card .top-actions { flex-shrink: 0; - margin-top: 0; + margin-top: auto; gap: clamp(6px, 1.4cqh, 8px); } @@ -649,8 +675,11 @@ .ptd-split-panel--right .ptd-order-card > .top-panel { gap: clamp(6px, 2cqh, 14px); } + .ptd-split-panel--right .ptd-order-card .top-panel-fields { + gap: clamp(6px, 1.8cqh, 12px); + } .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - gap: clamp(4px, 1.8cqh, 12px); + gap: clamp(6px, 1.8cqh, 12px); } } @@ -662,8 +691,8 @@ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 8px; - row-gap: 4px; - align-content: space-evenly; + row-gap: 6px; + align-content: start; } .ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; } .ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; } diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 2bbf495..0dda269 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -158,14 +158,100 @@ color: var(--se-node-start-accent, var(--se-gold)); } -.sp-start-tf { - font-size: 0.72rem; - padding: 3px 6px; - border-radius: 4px; - border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent); - background: color-mix(in srgb, var(--se-bg) 75%, transparent); - color: var(--se-text); +.sp-start-tf-checks { + flex: 1; + min-width: 0; +} + +.se-start-tf-checks { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; +} + +.se-start-tf-checks--compact { + gap: 2px 5px; +} + +.se-start-tf-check { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.62rem; + color: var(--se-text-muted); cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.se-start-tf-checks--compact .se-start-tf-check { + font-size: 0.58rem; +} + +.se-start-tf-check input { + margin: 0; + accent-color: var(--se-node-start-accent, var(--se-gold)); + cursor: pointer; +} + +.se-start-tf-check:has(input:checked) { + color: var(--se-text); + font-weight: 600; +} + +.se-flow-start-tf-checks-wrap { + width: 100%; +} + +.se-start-tf-checks--graph { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + column-gap: 2px; + row-gap: 4px; + width: 100%; +} + +.se-start-tf-checks-row { + display: contents; +} + +.se-start-tf-checks--graph .se-start-tf-check { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 3px; + font-size: 0.58rem; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +.se-start-tf-checks--graph .se-start-tf-check input { + flex-shrink: 0; +} + +.se-flow-start-layout { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; +} + +.se-flow-start-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + width: 100%; +} + +.se-flow-start-title-left { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; } .sp-start-del { @@ -877,12 +963,6 @@ border-color: color-mix(in srgb, var(--se-sell) 42%, transparent); box-shadow: 0 0 18px color-mix(in srgb, var(--se-sell) 16%, transparent); } -.se-flow-start-head { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: nowrap; -} .se-flow-start-label { font-weight: 700; font-size: 0.72rem; @@ -890,22 +970,6 @@ flex-shrink: 0; color: var(--se-node-start-accent, var(--se-gold)); } -.se-flow-start-tf { - flex: 1; - min-width: 0; - max-width: 72px; - font-size: 0.65rem; - padding: 2px 4px; - border-radius: 4px; - border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent); - background: color-mix(in srgb, var(--se-bg) 70%, transparent); - color: var(--se-text); - cursor: pointer; -} -.se-flow-start-tf:focus { - outline: none; - border-color: var(--se-accent); -} .se-flow-del--start { position: static; flex-shrink: 0; @@ -932,6 +996,19 @@ box-shadow: 0 0 20px color-mix(in srgb, var(--se-sell) 15%, transparent); } +.se-flow-gate-head { + display: flex; + align-items: center; + justify-content: center; +} +.se-logic-gate-toggle { + display: inline-flex; +} +.se-logic-gate-toggle--compact .se-start-combine-btn { + padding: 3px 9px; + font-size: 0.68rem; + min-width: 0; +} .se-flow-gate-badge { font-weight: 800; font-size: 0.88rem; diff --git a/frontend/src/styles/verificationBoard.css b/frontend/src/styles/verificationBoard.css index 5827883..515b858 100644 --- a/frontend/src/styles/verificationBoard.css +++ b/frontend/src/styles/verificationBoard.css @@ -850,16 +850,22 @@ background: var(--hover); } -/* 검증 이슈 알림 팝업 */ +/* 검증 이슈 알림 팝업 — body 포털(.app 밖) · 매매 시그널 팝업(tsn-)과 동일 톤 */ +html.theme-dark .vbd-toast-stack, +html.theme-light .vbd-toast-stack, +html.theme-blue .vbd-toast-stack { + color: var(--text); +} + .vbd-toast-stack { position: fixed; left: 16px; bottom: 16px; - z-index: 6500; + z-index: 20002; display: flex; flex-direction: column; gap: 10px; - width: min(360px, calc(100vw - 32px)); + width: min(380px, calc(100vw - 32px)); pointer-events: none; } @@ -869,31 +875,42 @@ justify-content: space-between; gap: 8px; pointer-events: auto; + padding-bottom: 2px; } .vbd-toast-toolbar-label { font-size: 11px; font-weight: 600; - color: var(--text-muted); + color: var(--text2, var(--text-muted)); } .vbd-toast-dismiss-all { - border: none; - background: transparent; - color: var(--accent); font-size: 11px; + padding: 5px 12px; + border-radius: 6px; + border: 1px solid var(--border, rgba(122, 162, 247, 0.2)); + background: var(--bg3, #1a1b26); + color: var(--text2, #a9b1d6); cursor: pointer; - padding: 2px 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.vbd-toast-dismiss-all:hover { + background: var(--bg4, var(--hover)); + color: var(--text); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } .vbd-toast-card { pointer-events: auto; - padding: 12px 14px; + padding: 12px 14px 10px; border-radius: 10px; - border: 1px solid var(--border); - background: var(--panel); - box-shadow: var(--popup-shadow); - animation: vbd-toast-in 0.22s ease-out; + border: 1px solid var(--border, rgba(122, 162, 247, 0.25)); + background: var(--bg2, #1e2030); + color: var(--text); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + animation: vbd-toast-in 0.28s cubic-bezier(0.22, 1, 0.36, 1); } @keyframes vbd-toast-in { @@ -915,50 +932,71 @@ .vbd-toast-kind { margin: 0 0 4px; font-size: 11px; - color: var(--text-muted); + font-weight: 500; + color: var(--text2, var(--text-muted)); + line-height: 1.35; } .vbd-toast-title { margin: 0; - font-size: 14px; + font-size: 13px; font-weight: 600; - line-height: 1.35; + line-height: 1.4; + color: var(--text); word-break: break-word; } .vbd-toast-close { + flex-shrink: 0; border: none; background: transparent; - color: var(--text-muted); - font-size: 14px; - cursor: pointer; - padding: 0 2px; + color: var(--text3, var(--text-muted)); + font-size: 18px; line-height: 1; + cursor: pointer; + padding: 0 4px; + transition: color 0.15s; +} + +.vbd-toast-close:hover { + color: var(--text); } .vbd-toast-meta { margin-top: 8px; } +.vbd-toast-meta .vbd-stage-badge { + font-size: 10px; +} + .vbd-toast-actions { display: flex; justify-content: flex-end; margin-top: 10px; + padding-top: 2px; } .vbd-toast-go { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 10px; + padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius: 6px; - background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + background: color-mix(in srgb, var(--accent) 12%, var(--bg2, var(--panel))); color: var(--accent); font-size: 12px; + font-weight: 500; cursor: pointer; + transition: background 0.15s, border-color 0.15s; } .vbd-toast-go:hover { - background: color-mix(in srgb, var(--accent) 18%, var(--panel)); + background: color-mix(in srgb, var(--accent) 22%, var(--bg2, var(--panel))); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); +} + +.vbd-toast-go svg { + flex-shrink: 0; } diff --git a/frontend/src/types/chartPaneSeparator.ts b/frontend/src/types/chartPaneSeparator.ts new file mode 100644 index 0000000..e9da110 --- /dev/null +++ b/frontend/src/types/chartPaneSeparator.ts @@ -0,0 +1,59 @@ +import type { Theme } from '../types'; +import type { HLineStyle } from '../utils/indicatorRegistry'; + +/** 캔들·거래량·보조지표 pane 사이 구분선 */ +export interface ChartPaneSeparatorOptions { + visible: boolean; + color: string; + width: 1 | 2 | 3 | 4; + lineStyle: HLineStyle; +} + +export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = { + visible: true, + color: '#2a2e3a', + width: 1, + lineStyle: 'solid', +}; + +const THEME_DEFAULT_COLORS: Record = { + dark: 'rgba(122, 162, 247, 0.15)', + light: 'rgba(0, 0, 0, 0.08)', + blue: 'rgba(94, 181, 255, 0.15)', +}; + +export function themeDefaultPaneSeparatorColor(theme: Theme): string { + return THEME_DEFAULT_COLORS[theme] ?? THEME_DEFAULT_COLORS.dark; +} + +export function resolveChartPaneSeparatorOptions( + raw?: Partial | null, + theme: Theme = 'dark', +): ChartPaneSeparatorOptions { + const base = { ...DEFAULT_CHART_PANE_SEPARATOR }; + if (!raw || typeof raw !== 'object') { + return { ...base, color: themeDefaultPaneSeparatorColor(theme) }; + } + if (typeof raw.visible === 'boolean') base.visible = raw.visible; + if (typeof raw.color === 'string' && raw.color.trim()) base.color = raw.color.trim(); + else base.color = themeDefaultPaneSeparatorColor(theme); + const w = Number(raw.width); + if (w >= 1 && w <= 4) base.width = Math.round(w) as 1 | 2 | 3 | 4; + if (raw.lineStyle === 'solid' || raw.lineStyle === 'dashed' || raw.lineStyle === 'dotted') { + base.lineStyle = raw.lineStyle; + } + return base; +} + +/** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */ +export function applyPaneSeparatorToChartOptions(opts: ChartPaneSeparatorOptions) { + return { + layout: { + panes: { + enableResize: false, + separatorColor: 'transparent', + separatorHoverColor: 'transparent', + }, + }, + } as const; +} diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 823ad88..f9ee88b 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -39,8 +39,15 @@ import { getIchimokuPlotTitle, isIchimokuCloudVisible, resolveIchimokuCloudColors, + resolveIchimokuDisplacements, } from './ichimokuConfig'; import type { PlotDef, HLineStyle } from './indicatorRegistry'; +import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; +import { + applyPaneSeparatorToChartOptions, + resolveChartPaneSeparatorOptions, + type ChartPaneSeparatorOptions, +} from '../types/chartPaneSeparator'; function plotSeriesLineStyle(style?: HLineStyle): number { if (style === 'dashed') return LineStyle.Dashed; @@ -217,6 +224,10 @@ export class ChartManager { /** 차트 하단 거래량 pane 표시 */ private _volumeVisible = true; + /** pane 간 구분선 (설정 화면) */ + private _paneSeparatorOptions: ChartPaneSeparatorOptions = + resolveChartPaneSeparatorOptions(null, 'dark'); + /** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */ private _lastLayoutAvailableHeight?: number; @@ -231,7 +242,7 @@ export class ChartManager { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor, fontSize: 12, - panes: { enableResize: false }, + panes: { enableResize: false, separatorColor: 'transparent', separatorHoverColor: 'transparent' }, }, grid: { vertLines: { color: t.gridColor }, @@ -258,6 +269,8 @@ export class ChartManager { }); this._applyDisplayTimezoneOptions(); + this._paneSeparatorOptions = resolveChartPaneSeparatorOptions(null, theme); + this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions)); // 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용) this.chart.subscribeCrosshairMove((params: MouseEventParams