From e43b5cbd5a857fcfafa63b9a3d7734d12f07a44b Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 29 May 2026 00:46:20 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=A4=EB=A7=A4=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=84=90=20=EC=95=8C=EB=A6=BC=20=ED=99=94=EB=A9=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/goldenchart/entity/GcAppSettings.java | 5 + .../service/AppSettingsService.java | 9 + .../StrategyConditionTimeframeService.java | 36 +- .../StrategyDslTimeframeNormalizer.java | 29 +- .../db/migration/V46__chart_time_format.sql | 2 + ...StrategyConditionTimeframeServiceTest.java | 43 +- .../StrategyDslTimeframeNormalizerTest.java | 56 +- frontend/src/App.css | 37 ++ frontend/src/App.tsx | 37 +- .../src/components/CandlePaneTimeAxis.tsx | 64 +++ frontend/src/components/ChartSlot.tsx | 2 + .../src/components/ChartTimeFormatPicker.tsx | 91 ++++ frontend/src/components/SettingsPage.tsx | 23 + frontend/src/components/TopMenuBar.tsx | 1 + .../components/TradeNotificationListPage.tsx | 112 ++-- frontend/src/components/TradingChart.tsx | 65 ++- .../TradeNotificationHScrollPane.tsx | 108 ++++ .../TradeNotificationListRow.tsx | 197 +++++-- .../TradeNotificationSignalCard.tsx | 37 ++ .../TradeNotificationSignalSummary.tsx | 102 ++++ .../TradeSignalChartCard.tsx | 3 + .../TradeSignalMiniChart.tsx | 9 +- .../TrendSearchCardSignalPanel.tsx | 20 +- .../virtual/VirtualSignalEqualizer.tsx | 31 +- .../components/virtual/VirtualTargetCard.tsx | 105 ++-- .../virtual/VirtualTargetCardFoot.tsx | 67 ++- .../components/virtual/VirtualTargetQuote.tsx | 6 +- .../virtual/VirtualTargetSignalPanel.tsx | 21 +- frontend/src/hooks/useAppSettings.ts | 12 + .../src/hooks/useSignalVisualRailHeight.ts | 35 ++ .../useTradeNotificationSignalSnapshot.ts | 63 +++ .../src/hooks/useVirtualIndicatorSnapshots.ts | 2 +- frontend/src/styles/strategyEditorTheme.css | 41 ++ frontend/src/styles/tradeNotificationList.css | 480 +++++++++++++++--- .../src/styles/virtualTradingDashboard.css | 131 ++++- frontend/src/utils/ChartManager.ts | 121 ++++- frontend/src/utils/appNavigationPaths.ts | 2 +- frontend/src/utils/backendApi.ts | 2 + frontend/src/utils/chartTimeFormat.ts | 214 ++++++++ .../src/utils/indicatorChartDefaultsSync.ts | 12 + frontend/src/utils/permissions.ts | 4 +- frontend/src/utils/strategyConditionSerde.ts | 18 +- frontend/src/utils/strategyTimeframeSync.ts | 60 ++- frontend/src/utils/timezone.ts | 84 +-- frontend/src/utils/tradeSignalDisplay.ts | 2 +- 45 files changed, 2186 insertions(+), 415 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V46__chart_time_format.sql create mode 100644 frontend/src/components/CandlePaneTimeAxis.tsx create mode 100644 frontend/src/components/ChartTimeFormatPicker.tsx create mode 100644 frontend/src/components/tradeNotification/TradeNotificationHScrollPane.tsx create mode 100644 frontend/src/components/tradeNotification/TradeNotificationSignalCard.tsx create mode 100644 frontend/src/components/tradeNotification/TradeNotificationSignalSummary.tsx create mode 100644 frontend/src/hooks/useSignalVisualRailHeight.ts create mode 100644 frontend/src/hooks/useTradeNotificationSignalSnapshot.ts create mode 100644 frontend/src/utils/chartTimeFormat.ts create mode 100644 frontend/src/utils/indicatorChartDefaultsSync.ts diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index 36176aa..c54289f 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -67,6 +67,11 @@ public class GcAppSettings { @Builder.Default private String displayTimezone = "Asia/Seoul"; + /** 차트 하단 시간축 표시 포맷 (예: yyyy-MM-dd HH:mm) */ + @Column(name = "chart_time_format", length = 64, nullable = false) + @Builder.Default + private String chartTimeFormat = "MM-dd HH:mm"; + /** 캔들 색상 JSON (frontend MainChartStyle 구조) */ @Column(name = "main_chart_style_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index f6ca527..8ccc01a 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -82,6 +82,7 @@ public class AppSettingsService { Boolean.parseBoolean(d.get("defaultLogScale").toString())); if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId")); if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone")); + if (d.containsKey("chartTimeFormat")) s.setChartTimeFormat(normalizeChartTimeFormat((String) d.get("chartTimeFormat"))); if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle"))); if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions"))); if (d.containsKey("btAutoPopup")) s.setBtAutoPopup( @@ -188,6 +189,7 @@ public class AppSettingsService { m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false); m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1"); m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul"); + m.put("chartTimeFormat", s.getChartTimeFormat() != null ? s.getChartTimeFormat() : "MM-dd HH:mm"); m.put("mainChartStyle", parseJson(s.getMainChartStyleJson())); m.put("syncOptions", parseJson(s.getSyncOptionsJson())); m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true); @@ -282,4 +284,11 @@ public class AppSettingsService { return null; } } + + private static String normalizeChartTimeFormat(String raw) { + if (raw == null) return "MM-dd HH:mm"; + String t = raw.trim(); + if (t.isEmpty() || !t.matches(".*[yMdHms].*")) return "MM-dd HH:mm"; + return t.length() > 64 ? t.substring(0, 64) : t; + } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 67b6919..22234dd 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -69,7 +69,7 @@ public class StrategyConditionTimeframeService { Set fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson()); if (!fromFlowLayout.isEmpty()) { - return fromFlowLayout; + return sanitizeEvaluationTimeframes(fromFlowLayout, strategy); } Set buyOut = new LinkedHashSet<>(); @@ -79,10 +79,10 @@ public class StrategyConditionTimeframeService { // 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록 if (buyScoped && !buyOut.isEmpty()) { - return buyOut; + return sanitizeEvaluationTimeframes(buyOut, strategy); } if (sellScoped && !sellOut.isEmpty()) { - return sellOut; + return sanitizeEvaluationTimeframes(sellOut, strategy); } Set out = new LinkedHashSet<>(); @@ -99,9 +99,39 @@ public class StrategyConditionTimeframeService { String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); return Set.of(fromName != null ? fromName : "1m"); } + return sanitizeEvaluationTimeframes(out, strategy); + } + + /** + * START/flow 에만 남은 stale 1m 제거 — DSL 조건에 명시된 분봉 기준. + */ + private Set sanitizeEvaluationTimeframes(Set scope, GcStrategy strategy) { + Set explicit = collectExplicitFromStrategy(strategy); + if (explicit.isEmpty()) return scope; + if (!explicit.contains("1m") && scope.contains("1m")) { + Set filtered = new LinkedHashSet<>(scope); + filtered.remove("1m"); + return filtered.isEmpty() ? scope : filtered; + } + return scope; + } + + private Set collectExplicitFromStrategy(GcStrategy strategy) { + Set out = new LinkedHashSet<>(); + collectExplicitFromJson(strategy.getBuyConditionJson(), out); + collectExplicitFromJson(strategy.getSellConditionJson(), out); return out; } + private void collectExplicitFromJson(String json, Set out) { + if (json == null || json.isBlank()) return; + try { + out.addAll(StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(objectMapper.readTree(json))); + } catch (Exception e) { + log.warn("[StrategyTimeframes] explicit JSON 파싱 실패: {}", e.getMessage()); + } + } + /** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */ private void ensureDslRepaired(GcStrategy strategy) { boolean changed = false; diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 4b9c480..5266a9c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -282,6 +282,13 @@ public class StrategyDslTimeframeNormalizer { if (fromConditions.size() == 1) { String required = fromConditions.iterator().next(); + // START에 5m 등 명시 분봉이 있으면 조건 노드 기본값 1m 보다 우선 (편집기 START 변경 직후) + if ("1m".equals(required) && hasExplicitNon1mStart(fromStart)) { + if (fromStart.size() == 1) { + return fromStart; + } + return dropStale1mFromStart(fromStart, fromConditions); + } // 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만 if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) { return Set.of(required); @@ -302,11 +309,21 @@ public class StrategyDslTimeframeNormalizer { // 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR) if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) { - return fromStart; + return dropStale1mFromStart(fromStart, fromConditions); } return fromConditions; } + /** START 에만 남은 stale 1m 제거 — 조건에 1m 이 없으면 평가·알림 분봉에서 제외 */ + private static Set dropStale1mFromStart(Set fromStart, Set fromConditions) { + if (!fromStart.contains("1m") || fromConditions.contains("1m")) { + return fromStart; + } + Set filtered = new LinkedHashSet<>(fromStart); + filtered.remove("1m"); + return filtered.isEmpty() ? fromStart : filtered; + } + private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) { Set resolved = resolveStartCandleTypes(root); if (resolved.isEmpty()) { @@ -333,6 +350,14 @@ public class StrategyDslTimeframeNormalizer { return fromStart.size() == 1 && fromStart.contains("1m"); } + /** START TIMEFRAME에 1m 이 아닌 분봉이 명시되어 있는지 */ + private static boolean hasExplicitNon1mStart(Set fromStart) { + for (String ct : fromStart) { + if (!"1m".equals(ct)) return true; + } + return false; + } + private ObjectNode reconcileStartCandleTypes(JsonNode tf) { return reconcileStartCandleTypesStatic(tf); } @@ -355,8 +380,6 @@ public class StrategyDslTimeframeNormalizer { long non1mCount = normalized.stream().filter(ct -> !"1m".equals(ct)).count(); if (non1mCount == 0) return copy; - // [1m, 3m, 5m] 등 1m+복수 상위봉 OR — 1m 의도적 선택으로 간주 - if (non1mCount >= 2) return copy; ArrayNode newArr = JsonNodeFactory.instance.arrayNode(); for (String ct : normalized) { diff --git a/backend/src/main/resources/db/migration/V46__chart_time_format.sql b/backend/src/main/resources/db/migration/V46__chart_time_format.sql new file mode 100644 index 0000000..da15477 --- /dev/null +++ b/backend/src/main/resources/db/migration/V46__chart_time_format.sql @@ -0,0 +1,2 @@ +ALTER TABLE gc_app_settings + ADD COLUMN chart_time_format VARCHAR(64) NOT NULL DEFAULT 'MM-dd HH:mm' AFTER display_timezone; diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index 90a1b5b..a4b2dde 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.util.List; import java.util.Optional; @@ -18,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class StrategyConditionTimeframeServiceTest { @Mock @@ -109,7 +112,11 @@ class StrategyConditionTimeframeServiceTest { "candleTypes": ["1m", "3m", "5m"], "children": [{ "type": "CONDITION", - "condition": { "indicatorType": "RSI", "period": 14 } + "condition": { + "indicatorType": "RSI", + "period": 14, + "leftCandleType": "1m" + } }] } """); @@ -235,6 +242,40 @@ class StrategyConditionTimeframeServiceTest { assertFalse(service.usesTimeframe(6L, "1m")); } + @Test + void collectForStrategy_cciMultiTfDropsStale1mFromStartArray() { + GcStrategy strategy = new GcStrategy(); + strategy.setId(7L); + strategy.setName("CCI | 3분 5분 10분 15분 1시간"); + strategy.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"], + "children": [{ + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } } + ] + }] + } + """); + when(strategyRepo.findById(7L)).thenReturn(Optional.of(strategy)); + when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> inv.getArgument(1)); + when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> inv.getArgument(0)); + + Set tfs = service.collectForStrategy(7L); + assertEquals(Set.of("3m", "5m", "10m", "15m", "1h"), tfs); + assertFalse(service.usesTimeframe(7L, "1m")); + assertTrue(service.usesTimeframe(7L, "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 index 12591eb..016364a 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -76,6 +76,31 @@ class StrategyDslTimeframeNormalizerTest { assertEquals("5m", root.path("candleType").asText()); } + @Test + void resolveStartCandleTypes_explicitStart5mBeatsConditionDefault1m() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "5m", + "candleTypes": ["5m"], + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "leftCandleType": "1m", + "rightCandleType": "1m" + } + }] + } + """; + JsonNode root = objectMapper.readTree(json); + assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root)); + String normalized = normalizer.normalizeJson(json, "cci_5분봉"); + JsonNode out = objectMapper.readTree(normalized); + assertEquals("5m", out.path("candleType").asText()); + assertEquals("5m", out.path("candleTypes").get(0).asText()); + } + @Test void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception { String json = """ @@ -161,6 +186,31 @@ class StrategyDslTimeframeNormalizerTest { assertEquals("15m", root.path("candleType").asText()); } + @Test + void resolveStartCandleTypes_dropsStale1mWhenConditionsUseMultipleUpperTfs() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"], + "children": [{ + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } } + ] + }] + } + """; + JsonNode root = objectMapper.readTree(json); + assertEquals( + Set.of("3m", "5m", "10m", "15m", "1h"), + StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root)); + } + @Test void normalize_preservesMultiCandleTypesOnSave() throws Exception { String json = """ @@ -170,7 +220,11 @@ class StrategyDslTimeframeNormalizerTest { "candleTypes": ["1m", "3m", "5m"], "children": [{ "type": "CONDITION", - "condition": { "indicatorType": "RSI", "period": 14 } + "condition": { + "indicatorType": "RSI", + "period": 14, + "leftCandleType": "1m" + } }] } """; diff --git a/frontend/src/App.css b/frontend/src/App.css index eaed00d..d76a035 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -899,6 +899,28 @@ html.theme-blue { height: 100%; } +/* 캔들 pane 하단 시간축 (거래량·보조지표가 아래에 있을 때) */ +.candle-pane-time-axis { + position: absolute; + left: 0; + z-index: 12; + display: flex; + align-items: center; + pointer-events: none; + border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85)); + background: color-mix(in srgb, var(--bg, #131722) 88%, transparent); + overflow: hidden; +} +.candle-pane-time-axis__label { + position: absolute; + transform: translateX(-50%); + font-size: 11px; + line-height: 1; + color: var(--text2, #787b86); + white-space: nowrap; + user-select: none; +} + /* Loading overlay */ /* 과거 데이터 추가 로드 중 표시 (차트 좌상단 소형 배지) */ .chart-history-loading { @@ -9376,6 +9398,21 @@ html.theme-blue { .stg-badge--off { background: rgba(158,158,158,0.2); color: var(--text2); margin-left: 8px; } .stg-hint { margin: 0 0 8px; padding: 0 4px; font-size: 12px; color: var(--text2); line-height: 1.5; } +.stg-chart-time-format { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 360px; +} +.stg-chart-time-format-custom { + width: 100%; + max-width: 280px; +} +.stg-chart-time-format-preview { + margin-top: 2px; + font-family: ui-monospace, monospace; +} + /* ── 연결 테스트 버튼 ── */ .stg-btn-test { padding: 5px 12px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca60484..c746778 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,11 @@ import FibTZSettingsModal from './components/FibTZSettingsModal'; import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot'; import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes'; import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator'; +import { + normalizeChartTimeFormat, + setChartTimeFormat, + useChartTimeFormat, +} from './utils/chartTimeFormat'; import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone'; import { calcStats, type PriceStats } from './utils/calculations'; import { getKoreanName } from './utils/marketNameCache'; @@ -47,6 +52,10 @@ import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader'; import { useMarketTicker } from './hooks/useMarketTicker'; import { useUpbitOrderbook } from './hooks/useUpbitOrderbook'; import { useIndicatorSettings } from './hooks/useIndicatorSettings'; +import { + consumeSkipNextChartDefaultsSync, + markChartIndicatorSaveCommitted, +} from './utils/indicatorChartDefaultsSync'; import { duplicateIndicatorInList, mergeIndicators, @@ -182,6 +191,7 @@ function App() { const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey); const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey); const displayTimezone = useDisplayTimezone(); + const chartTimeFormat = useChartTimeFormat(); // DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨 const [symbol, setSymbol] = useState(initial.symbol); @@ -253,8 +263,16 @@ function App() { setDisplayTimezone(next); saveAppDef({ displayTimezone: next }); const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; - managerRef.current?.setDisplayTimezone(next, tf); - }, [saveAppDef, layoutDef.count, activeSlotTf, timeframe]); + managerRef.current?.setDisplayTimezone(next, tf, chartTimeFormat); + }, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, chartTimeFormat]); + + const handleChartTimeFormatChange = useCallback((format: string) => { + const next = normalizeChartTimeFormat(format); + setChartTimeFormat(next); + saveAppDef({ chartTimeFormat: next }); + const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; + managerRef.current?.setDisplayTimezone(displayTimezone, tf, next); + }, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, displayTimezone]); const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol; const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol); @@ -957,8 +975,8 @@ function App() { useEffect(() => { if (!appSettingsLoaded) return; const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; - managerRef.current?.setDisplayTimezone(displayTimezone, tf); - }, [appSettingsLoaded, displayTimezone, timeframe, activeSlotTf, layoutDef.count]); + managerRef.current?.setDisplayTimezone(displayTimezone, tf, chartTimeFormat); + }, [appSettingsLoaded, displayTimezone, chartTimeFormat, timeframe, activeSlotTf, layoutDef.count]); useEffect(() => { if (!appSettingsLoaded) return; @@ -1484,6 +1502,7 @@ function App() { const fromTemplate = isIndicatorSettingsTemplateId(settingsModalId ?? '') || updated.id.startsWith('template_'); + markChartIndicatorSaveCommitted(); saveParams(updated.type, updated.params); saveVisual( updated.type, @@ -1554,6 +1573,10 @@ function App() { useLayoutEffect(() => { if (!chartVisible) return; if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return; + if (consumeSkipNextChartDefaultsSync()) { + lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision; + return; + } syncChartsFromSavedDefaults(); lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision; }, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]); @@ -1756,7 +1779,7 @@ function App() { )} {/* ── 설정 화면 ──────────────────────────────────────────────────── */} - {menuPage === 'notifications' && ( + {menuPage === 'notifications' && canMenu('notifications') && ( saveAppDef({ fcmPushEnabled: v })} displayTimezone={displayTimezone} onDisplayTimezoneChange={handleTimezoneChange} + chartTimeFormat={chartTimeFormat} + onChartTimeFormatChange={handleChartTimeFormatChange} verificationIssueNotify={appDefaults.verificationIssueNotify} onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })} onFcmTest={async () => { @@ -2256,7 +2281,7 @@ function App() { const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); mgr.setPaneSeparatorOptions(chartPaneSeparator); - mgr.setDisplayTimezone(displayTimezone, timeframe); + mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 mgr.subscribeVisibleLogicalRange(r => { diff --git a/frontend/src/components/CandlePaneTimeAxis.tsx b/frontend/src/components/CandlePaneTimeAxis.tsx new file mode 100644 index 0000000..e00d975 --- /dev/null +++ b/frontend/src/components/CandlePaneTimeAxis.tsx @@ -0,0 +1,64 @@ +/** + * 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시. + */ +import React, { useState, useEffect, useCallback } from 'react'; +import type { ChartManager } from '../utils/ChartManager'; + +const AXIS_HEIGHT = 22; + +interface CandlePaneTimeAxisProps { + manager: ChartManager; + containerEl: HTMLElement; +} + +const CandlePaneTimeAxis: React.FC = ({ manager, containerEl }) => { + const [state, setState] = useState>(null); + + const sync = useCallback(() => { + if (!containerEl.isConnected) return; + setState(manager.getCandlePaneTimeAxisOverlay()); + }, [manager, containerEl]); + + useEffect(() => { + sync(); + const ro = new ResizeObserver(sync); + ro.observe(containerEl); + + const unsubLayout = manager.subscribePaneLayout(sync); + const unsubRange = manager.subscribeViewport(sync); + + const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms)); + return () => { + ro.disconnect(); + unsubLayout(); + unsubRange(); + timers.forEach(clearTimeout); + }; + }, [manager, containerEl, sync]); + + if (!state || state.labels.length === 0) return null; + + return ( +
+ {state.labels.map((label, idx) => ( + + {label.text} + + ))} +
+ ); +}; + +export default CandlePaneTimeAxis; diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index c0924a3..a0025f0 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -27,6 +27,7 @@ import { invalidateMarketCache } from '../utils/requestCache'; import { DISPLAY_COUNT } from '../hooks/useUpbitData'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; +import { markChartIndicatorSaveCommitted } from '../utils/indicatorChartDefaultsSync'; import { duplicateIndicatorInList, mergeIndicators, @@ -568,6 +569,7 @@ const ChartSlot = forwardRef(function ChartSlot setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i)); setSettingsModalId(null); setCtxToolbar(null); + markChartIndicatorSaveCommitted(); // 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영 saveParams(updated.type, updated.params); // 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용 diff --git a/frontend/src/components/ChartTimeFormatPicker.tsx b/frontend/src/components/ChartTimeFormatPicker.tsx new file mode 100644 index 0000000..9d7e697 --- /dev/null +++ b/frontend/src/components/ChartTimeFormatPicker.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + CHART_TIME_FORMAT_PRESETS, + formatUnixWithChartPattern, + isPresetChartTimeFormat, + normalizeChartTimeFormat, +} from '../utils/chartTimeFormat'; + +const CUSTOM_VALUE = '__custom__'; + +interface ChartTimeFormatPickerProps { + value: string; + onChange: (format: string) => void; +} + +const ChartTimeFormatPicker: React.FC = ({ value, onChange }) => { + const normalized = normalizeChartTimeFormat(value); + const isPreset = isPresetChartTimeFormat(normalized); + + const [selectValue, setSelectValue] = useState( + isPreset ? normalized : CUSTOM_VALUE, + ); + const [customText, setCustomText] = useState( + isPreset ? '' : normalized, + ); + + useEffect(() => { + const n = normalizeChartTimeFormat(value); + if (isPresetChartTimeFormat(n)) { + setSelectValue(n); + setCustomText(''); + } else { + setSelectValue(CUSTOM_VALUE); + setCustomText(n); + } + }, [value]); + + const preview = useMemo(() => { + const fmt = selectValue === CUSTOM_VALUE + ? normalizeChartTimeFormat(customText) + : selectValue; + return formatUnixWithChartPattern(Math.floor(Date.now() / 1000), fmt); + }, [selectValue, customText]); + + const commitCustom = useCallback(() => { + const next = normalizeChartTimeFormat(customText); + onChange(next); + }, [customText, onChange]); + + return ( +
+ + {selectValue === CUSTOM_VALUE && ( + setCustomText(e.target.value)} + onBlur={commitCustom} + onKeyDown={e => { if (e.key === 'Enter') commitCustom(); }} + /> + )} + + 미리보기: {preview} + + + 토큰: yyyy, yy, MM, dd, HH, mm, ss + +
+ ); +}; + +export default ChartTimeFormatPicker; diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 9975747..776ef8f 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -43,6 +43,7 @@ import { LINE_WIDTH_OPTIONS, } from '../utils/plotColorUtils'; import TimezonePicker from './TimezonePicker'; +import ChartTimeFormatPicker from './ChartTimeFormatPicker'; import AdminPasswordGate from './AdminPasswordGate'; import AdminSettingsPanel from './AdminSettingsPanel'; import { @@ -152,6 +153,8 @@ interface SettingsPageProps { onFcmTest?: () => void; displayTimezone?: string; onDisplayTimezoneChange?: (tz: string) => void; + chartTimeFormat?: string; + onChartTimeFormatChange?: (format: string) => void; menuPermissions?: Record; verificationIssueNotify?: boolean; onVerificationIssueNotify?: (v: boolean) => void; @@ -651,6 +654,8 @@ interface ChartPanelProps { onChartLegendOptionsChange?: (patch: Partial) => void; chartPaneSeparator?: ChartPaneSeparatorOptions; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; + chartTimeFormat?: string; + onChartTimeFormatChange?: (format: string) => void; } const ChartPanel: React.FC = ({ @@ -667,6 +672,8 @@ const ChartPanel: React.FC = ({ onChartLegendOptionsChange, chartPaneSeparator, onChartPaneSeparatorChange, + chartTimeFormat = 'MM-dd HH:mm', + onChartTimeFormatChange, }) => { const [upColor, setUp] = useState('#26a69a'); const [downColor, setDown] = useState('#ef5350'); @@ -678,6 +685,18 @@ const ChartPanel: React.FC = ({ return ( <> + + + onChartTimeFormatChange?.(fmt)} + /> + + + { - onStrategyChange?.(parseTargetStrategySelectValue(e.target.value)); - }} - > - - {strategies.map(s => ( - - ))} - - + readOnlyStrategyLabel, +}) => { + const resolvedName = + readOnlyStrategyLabel?.trim() + || strategies.find(s => s.id === strategyId)?.name + || ''; + + return ( +
+ 갱신 {formatUpdatedTime(snapshot?.updatedAt)} +
e.stopPropagation()}> + +
- -); + ); +}; export default VirtualTargetCardFoot; diff --git a/frontend/src/components/virtual/VirtualTargetQuote.tsx b/frontend/src/components/virtual/VirtualTargetQuote.tsx index df2b587..b63aba9 100644 --- a/frontend/src/components/virtual/VirtualTargetQuote.tsx +++ b/frontend/src/components/virtual/VirtualTargetQuote.tsx @@ -27,9 +27,11 @@ interface Props { ticker?: TickerData; /** 카드 메타 행 — 현재가만 간략 표시 */ compact?: boolean; + /** compact 시 "현재가" 라벨 표시 (기본 true) */ + showPriceLabel?: boolean; } -const VirtualTargetQuote: React.FC = ({ market, ticker, compact = false }) => { +const VirtualTargetQuote: React.FC = ({ market, ticker, compact = false, showPriceLabel = true }) => { const code = market.replace(/^KRW-/, ''); const change: ChangeDir = ticker?.change ?? 'EVEN'; const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even'; @@ -54,7 +56,7 @@ const VirtualTargetQuote: React.FC = ({ market, ticker, compact = false } if (compact) { return (
- 현재가 + {showPriceLabel && 현재가} {ticker ? fmtKrw(ticker.tradePrice) : '-'} diff --git a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx index c8b59ac..d62d95e 100644 --- a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx +++ b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { buildConditionMetrics, @@ -36,6 +37,12 @@ const VirtualTargetSignalPanel: React.FC = ({ [metrics, snapshot?.matchRate], ); const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]); + const { visualRef, lightRef } = useSignalVisualRailHeight([ + rows.length, + matchRate, + trafficState, + viewMode, + ]); if (rows.length === 0) { return ( @@ -58,12 +65,18 @@ const VirtualTargetSignalPanel: React.FC = ({ >
SIGNAL INTELLIGENCE & MATCH RATES
-
-
+
+
- - +
+ +
+
+
+ +
+
{isDetail && ( diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index af01f14..a8760d4 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -36,6 +36,11 @@ import { resolveChartLegendOptions } from '../types/chartLegend'; import type { ChartLegendVisibility } from '../types/chartLegend'; import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage'; import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes'; +import { + DEFAULT_CHART_TIME_FORMAT, + normalizeChartTimeFormat, + setChartTimeFormat, +} from '../utils/chartTimeFormat'; import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone'; import { clampVirtualTargetMax, @@ -170,6 +175,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95, fcmPushEnabled: s.fcmPushEnabled ?? false, displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE), + chartTimeFormat: normalizeChartTimeFormat(s.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT), trendSearchSettings: resolveTrendSearchAppSettings( s.trendSearchSettings as Partial | null | undefined, ), @@ -194,6 +200,7 @@ export function useAppSettings(sessionKey = 0) { if (_cache !== null) { setSettings(_cache); setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); + setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setIsLoaded(true); } else { setIsLoaded(false); @@ -202,6 +209,7 @@ export function useAppSettings(sessionKey = 0) { } else if (_cache !== null) { setSettings(_cache); setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); + setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setIsLoaded(true); } else { setIsLoaded(false); @@ -211,6 +219,7 @@ export function useAppSettings(sessionKey = 0) { if (mountedRef.current) { setSettings(data); setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); + setChartTimeFormat(normalizeChartTimeFormat(data.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setIsLoaded(true); } }).catch(err => { @@ -238,6 +247,9 @@ export function useAppSettings(sessionKey = 0) { if (patch.displayTimezone != null) { setDisplayTimezone(normalizeTimezone(patch.displayTimezone)); } + if (patch.chartTimeFormat != null) { + setChartTimeFormat(normalizeChartTimeFormat(patch.chartTimeFormat)); + } saveAppSettings(patch).then(updated => { if (updated) { _cache = { ...(_cache ?? {}), ...updated }; diff --git a/frontend/src/hooks/useSignalVisualRailHeight.ts b/frontend/src/hooks/useSignalVisualRailHeight.ts new file mode 100644 index 0000000..835ddc7 --- /dev/null +++ b/frontend/src/hooks/useSignalVisualRailHeight.ts @@ -0,0 +1,35 @@ +import { useLayoutEffect, useRef } from 'react'; + +/** + * 신호등+하단 %/문구(.vtd-sig-light-rail) 높이를 측정해 + * 이퀄라이저 pane 눈금·막대 높이(--vtd-sig-rail-height)와 동기화 + */ +export function useSignalVisualRailHeight(deps: unknown[] = []) { + const visualRef = useRef(null); + const lightRef = useRef(null); + + useLayoutEffect(() => { + const visual = visualRef.current; + const light = lightRef.current; + if (!visual || !light) return; + + const sync = () => { + const h = Math.ceil(light.getBoundingClientRect().height); + if (h > 0) { + visual.style.setProperty('--vtd-sig-rail-height', `${h}px`); + } + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(light); + window.addEventListener('resize', sync); + return () => { + ro.disconnect(); + window.removeEventListener('resize', sync); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { visualRef, lightRef }; +} diff --git a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts new file mode 100644 index 0000000..75f9afb --- /dev/null +++ b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts @@ -0,0 +1,63 @@ +/** + * 매매 시그널 알림 목록 행 — 종목×전략 실시간 신호 스냅샷 (가상매매 카드 요약형과 동일 소스) + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { StrategyDto } from '../utils/backendApi'; +import { + fetchLiveSnapshot, + type VirtualIndicatorSnapshot, +} from './useVirtualIndicatorSnapshots'; + +export function useTradeNotificationSignalSnapshot( + market: string, + strategyId: number | null | undefined, + strategy: StrategyDto | undefined, + enabled: boolean, + pollMs = 3000, +): { snapshot: VirtualIndicatorSnapshot | undefined; loading: boolean } { + const [snapshot, setSnapshot] = useState(); + const [loading, setLoading] = useState(false); + const emptyRetryRef = useRef(0); + const genRef = useRef(0); + + const refresh = useCallback(async () => { + if (!enabled || strategyId == null) { + setSnapshot(undefined); + setLoading(false); + return; + } + + const gen = ++genRef.current; + setLoading(true); + const pinFirst = emptyRetryRef.current === 0; + const snap = await fetchLiveSnapshot(market, strategyId, strategy, pinFirst); + if (gen !== genRef.current) return; + + if (snap && snap.rows.length > 0) { + emptyRetryRef.current = 0; + } else { + emptyRetryRef.current += 1; + } + + setSnapshot(snap ?? undefined); + setLoading(false); + }, [market, strategyId, strategy, enabled]); + + useEffect(() => { + if (!enabled || strategyId == null) { + setSnapshot(undefined); + setLoading(false); + emptyRetryRef.current = 0; + return; + } + emptyRetryRef.current = 0; + void refresh(); + const id = window.setInterval(() => void refresh(), pollMs); + return () => { + clearInterval(id); + genRef.current += 1; + }; + }, [market, strategyId, enabled, pollMs, refresh]); + + return { snapshot, loading }; +} diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index c750066..8b0135b 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -108,7 +108,7 @@ function staticSnapshot( } /** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */ -async function fetchLiveSnapshot( +export async function fetchLiveSnapshot( market: string, strategyId: number, strategy: StrategyDto | undefined, diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index fe26c99..7fe9389 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -111,6 +111,47 @@ --vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45); } +/* BuilderPageShell 없이 bps-page--vtd 만 쓰는 화면(매매 시그널 알림 등) */ +.bps-page--vtd.se-page--dark, +.bps-page--vtd.se-page--blue, +.bps-page--vtd:not(.se-page--light) { + --vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #e6c200) 90%, #fff); + --vtd-sig-panel-bg: color-mix(in srgb, #0d111f 60%, transparent); + --vtd-sig-card-gradient: color-mix(in srgb, #1a2035 92%, #000); + --vtd-sig-eq-bg: color-mix(in srgb, #000 35%, transparent); + --vtd-heat-track-bg: color-mix(in srgb, #060a14 85%, #000); + --vtd-heat-label-color: color-mix(in srgb, var(--se-text, var(--text)) 88%, #fff); + --vtd-heat-label-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8); + --vtd-sig-table-bg: color-mix(in srgb, #0d111f 50%, transparent); + --vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #e6c200) 6%, transparent); + --vtd-cond-item-bg: color-mix(in srgb, var(--bg2, #1a1b26) 70%, #000); + --vtd-light-housing-bg: linear-gradient(180deg, #2a2a2a, #1a1a1a); + --vtd-light-housing-border: color-mix(in srgb, #555 80%, #888); + --vtd-status-warm: #ffe082; + --vtd-status-hot: #82b1ff; + --vtd-status-cold: #ff8a80; + --vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45); +} + +.bps-page--vtd.se-page--light { + --vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #f57f17) 88%, #212121); + --vtd-sig-panel-bg: color-mix(in srgb, var(--se-gold, #f57f17) 5%, #ffffff); + --vtd-sig-card-gradient: color-mix(in srgb, var(--se-gold, #f57f17) 7%, #ffffff); + --vtd-sig-eq-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 40%, #ffffff); + --vtd-heat-track-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 55%, #ffffff); + --vtd-heat-label-color: var(--se-text, var(--text)); + --vtd-heat-label-shadow: none; + --vtd-sig-table-bg: #ffffff; + --vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #f57f17) 12%, #f5f5f5); + --vtd-cond-item-bg: var(--bg2, #ffffff); + --vtd-light-housing-bg: linear-gradient(180deg, #eceff1, #cfd8dc); + --vtd-light-housing-border: rgba(0, 0, 0, 0.18); + --vtd-status-warm: #e65100; + --vtd-status-hot: #1565c0; + --vtd-status-cold: #c62828; + --vtd-heat-inset: inset 0 1px 3px rgba(0, 0, 0, 0.1); +} + .se-page--light { --se-gold: #f57f17; --se-success: #2e7d32; diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index e3e037c..4dafc4f 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -1,34 +1,341 @@ /* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */ +/* 실시간 WS/STOMP 수신 — 목록 행(.tnl-row) 외곽 아웃라인만 형광 연두 (내부 inset 없음) */ +.tnl-row.tnl-row--receiving { + border-color: #69f0ae !important; + box-shadow: + 0 0 0 1px #69f0ae, + 0 0 10px 4px #69f0ae66, + 0 0 22px 8px #b9f6ca33; + transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out; +} + +/* 수신 시 알림 상태 카드(좌측 요약)에는 연두 효과 미적용 */ +.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane, +.tnl-row.tnl-row--receiving .tnl-summary-card { + box-shadow: none !important; + background: var(--se-panel-card-bg, var(--bg2)); + transition: border-color 0.15s ease-out; +} + +.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane { + border-color: var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); +} + +.tnl-row.tnl-row--receiving .tnl-summary-card--buy { + border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border))); +} + +.tnl-row.tnl-row--receiving .tnl-summary-card--sell { + border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border))); +} + +.tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected { + border-color: var(--accent, #7aa2f7); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); +} + .tnl-row--gallery { display: block; + width: 100%; overflow: visible; + box-sizing: border-box; } .tnl-row-gallery { + --tnl-summary-card-width: 320px; display: flex; flex-direction: row; - align-items: flex-start; + align-items: stretch; gap: 10px; + width: 100%; height: 320px; max-height: 320px; padding: 10px; box-sizing: border-box; } -/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */ -.tnl-summary-card { - flex: 0 0 544px; - width: 544px; +/* 좌측 알림 요약 카드 — 고정 너비(모든 행 동일) · 우측은 차트 영역이 나머지 공간 사용 */ +.tnl-row-gallery-detail { + flex: 0 0 var(--tnl-summary-card-width); + width: var(--tnl-summary-card-width); + min-width: var(--tnl-summary-card-width); + max-width: var(--tnl-summary-card-width); +} + +.tnl-row-gallery-charts { + flex: 1 1 0; + min-width: 0; + width: auto; +} + +.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail { + flex: 0 0 var(--tnl-summary-card-width); + width: var(--tnl-summary-card-width); + min-width: var(--tnl-summary-card-width); + max-width: var(--tnl-summary-card-width); +} + +.tnl-row-gallery--chart-expanded .tnl-row-gallery-charts { + flex: 1 1 0; + min-width: 0; +} + +/* 가로 스크롤 + 우측 세로 레일 버튼 */ +.tnl-hscroll-pane { + display: flex; + flex-direction: row; + align-items: stretch; + min-width: 0; + min-height: 0; height: 300px; max-height: 300px; - align-self: flex-start; + border-radius: 12px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); + overflow: hidden; + box-sizing: border-box; +} + +.tnl-hscroll-pane__viewport { + flex: 1 1 0; + min-width: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text3) 35%, transparent) transparent; +} + +.tnl-hscroll-pane__viewport::-webkit-scrollbar { + height: 6px; +} + +.tnl-hscroll-pane__viewport::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text3) 35%, transparent); + border-radius: 4px; +} + +.tnl-hscroll-pane__content { + display: flex; + flex-direction: row; + align-items: stretch; + height: 100%; + min-height: 0; + box-sizing: border-box; +} + +.tnl-row-gallery-charts .tnl-hscroll-pane__content { + min-width: 100%; + width: 100%; +} + +.tnl-row-gallery-charts .tnl-hscroll-pane__viewport { + overflow-x: hidden; +} + +.tnl-hscroll-pane__rail { + flex: 0 0 32px; + width: 32px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 8px; + padding: 8px 4px; + border-left: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg3, var(--bg2)) 85%, transparent); + box-sizing: border-box; +} + +.tnl-hscroll-pane__rail-btn { + flex: 1 1 0; + min-height: 44px; + max-height: 96px; + margin: 0; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 28%, var(--se-border, var(--border))); + border-radius: 8px; + background: color-mix(in srgb, var(--bg2) 90%, transparent); + color: var(--accent, #3f7ef5); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, opacity 0.15s, transform 0.1s; +} + +.tnl-hscroll-pane__rail-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #3f7ef5) 16%, var(--bg2)); + border-color: var(--accent, #3f7ef5); +} + +.tnl-hscroll-pane__rail-btn:active:not(:disabled) { + transform: scale(0.96); +} + +.tnl-hscroll-pane__rail-btn:disabled { + opacity: 0.35; + cursor: default; + color: var(--text3); + border-color: var(--se-border, var(--border)); +} + +/* 좌측 알림 상세(매매 시그널 요약 카드) */ +.tnl-row-gallery-detail .tnl-hscroll-pane__viewport { + overflow-x: hidden; +} + +.tnl-row-gallery-detail .tnl-hscroll-pane__content { + min-width: 100%; + width: 100%; +} + +.tnl-row-gallery-detail .tnl-hscroll-pane { + width: 100%; +} + +.tnl-row-gallery-detail .tnl-summary-card { + flex: 1 1 auto; + width: 100%; + min-width: 0; + max-width: none; + height: 100%; +} + +/* 신호상세 — 가상매매 vtd-card 슬롯 (이퀄라이저·히트맵·신호등 최소 너비 확보) */ +.tnl-signal-slot { + flex: 1.35 1 300px; + width: auto; + min-width: min(100%, 300px); + max-width: 42%; + height: 280px; + max-height: 280px; + display: flex; + flex-direction: column; + min-height: 0; + box-sizing: border-box; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: visible; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card { + flex: 1; + width: 100%; + height: 100%; + min-height: 0; + max-height: 100%; + align-self: stretch; + overflow: visible; + box-sizing: border-box; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-sig-panel-wrap--summary { + flex: 0 0 auto; + min-height: 0; + overflow: visible; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-panel--summary { + flex: 0 0 auto; + overflow: visible; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-visual { + flex: 0 0 auto; + min-height: 0; + overflow: visible; +} + +/* 가상매매와 동일 vtd-sig-* (strategyEditorTheme + virtualTradingDashboard) */ + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-card-chart-panel { + flex: 1; + min-height: 0; +} + +.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-card-foot-select:disabled { + opacity: 1; + cursor: default; + color: var(--se-text); + max-width: 200px; +} + +/* 신호 상세 헤더 — 우측 현재가·등락·실시간 (아이콘/토글 버튼 없음) */ +.tnl-signal-slot .vtd-card--head-inline-quote .vtd-card-head { + flex-shrink: 0; + padding-bottom: 2px; +} + +.tnl-signal-slot .vtd-card-head-quote { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.tnl-signal-slot .vtd-card-head-quote .vtd-target-quote--compact { + display: inline-flex; + align-items: baseline; + gap: 6px; + margin: 0; + padding: 0; + flex: 0 1 auto; + min-width: 0; +} + +.tnl-signal-slot .vtd-card-head-quote .vtd-target-price { + font-size: 13px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.tnl-signal-slot .vtd-card-head-quote .vtd-target-change { + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.tnl-signal-slot .vtd-card-head-quote .vtd-live-badge { + flex-shrink: 0; +} + +.tnl-charts-track > .tnl-signal-slot { + flex: 1.35 1 300px; + min-width: min(100%, 300px); +} + +/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */ +.tnl-summary-card { + flex: 1 1 auto; + width: 100%; + min-width: 0; + height: 100%; + max-height: 100%; + align-self: stretch; display: flex; flex-direction: column; border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border))); border-radius: 12px; background: var(--se-panel-card-bg, var(--bg2)); overflow: hidden; + box-sizing: border-box; +} + +.tnl-row-gallery-detail .tnl-summary-card { + min-width: 0; } .tnl-summary-card--buy { @@ -71,8 +378,10 @@ .tnl-summary-title { display: flex; - flex-direction: column; - gap: 4px; + flex-direction: row; + align-items: baseline; + flex-wrap: nowrap; + gap: 8px; min-width: 0; flex: 1; } @@ -82,6 +391,7 @@ font-weight: 700; line-height: 1.2; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -91,6 +401,8 @@ font-size: 12px; font-weight: 600; color: var(--text3, var(--se-text-muted)); + white-space: nowrap; + flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -270,25 +582,23 @@ flex-shrink: 0; } -.tnl-row-gallery--chart-expanded .tnl-charts-scroll { - display: none; -} - -.tnl-charts-expanded { - flex: 1; - min-width: 0; - height: 300px; - max-height: 300px; +.tnl-charts-expanded-inner { display: flex; flex-direction: column; - border-radius: 12px; - border: 1px solid var(--se-border, var(--border)); - background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); - overflow: hidden; + flex: 1 1 auto; + min-width: 100%; + width: 100%; + height: 100%; + padding: 0 4px 4px; box-sizing: border-box; } -.tnl-charts-expanded .tnl-chart-card--expanded { +.tnl-row-gallery-charts--expanded .tnl-hscroll-pane__content { + min-width: 100%; + width: 100%; +} + +.tnl-charts-expanded-inner .tnl-chart-card--expanded { flex: 1; width: 100%; height: 100%; @@ -296,48 +606,33 @@ border: none; border-radius: 0; gap: 8px; - padding: 0 4px 4px; box-sizing: border-box; } -.tnl-charts-scroll { - flex: 1; - min-width: 0; - height: 300px; - max-height: 300px; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - overscroll-behavior-x: contain; - border-radius: 12px; - border: 1px solid var(--se-border, var(--border)); - background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); -} - -.tnl-charts-scroll::-webkit-scrollbar { - height: 8px; -} - -.tnl-charts-scroll::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--text3) 35%, transparent); - border-radius: 4px; -} - .tnl-charts-track { display: flex; flex-direction: row; - align-items: flex-start; + align-items: stretch; gap: 10px; padding: 10px; height: 100%; box-sizing: border-box; - width: max-content; + width: 100%; + min-width: 0; +} + +.tnl-charts-track > .tnl-chart-card { + flex: 1 1 0; + width: 0; + min-width: 0; + max-width: none; } .tnl-chart-card { position: relative; - flex: 0 0 300px; - width: 300px; + flex: 1 1 0; + width: 0; + min-width: 0; height: 280px; max-height: 280px; display: flex; @@ -494,35 +789,20 @@ gap: 12px; } -/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */ -.tnl-header-row { +/* 툴바 — 좌: 필터·액션 · 우: 목록/그리드·배치·삭제 */ +.tnl-page--with-right .tnl-toolbar { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 14px; + flex-wrap: wrap; + align-items: center; + gap: 10px; } -.tnl-header-intro { - flex: 1; - min-width: 0; -} - -.tnl-header-intro .tnl-title { - margin-bottom: 6px; -} - -.tnl-header-intro .tnl-sub { - margin-bottom: 0; -} - -.tnl-header-actions { +.tnl-toolbar-end { display: flex; align-items: center; - justify-content: flex-end; gap: 8px; + margin-left: auto; flex-shrink: 0; - padding-top: 2px; } .tnl-layout-toggle { @@ -735,30 +1015,70 @@ ul.tnl-list.tnl-list--grid { box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); } -.tnl-row--gallery.tnl-row--selected .tnl-summary-card, -.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll, -.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded { +.tnl-row--gallery.tnl-row--selected .tnl-hscroll-pane, +.tnl-row--gallery.tnl-row--selected .tnl-summary-card--selected { border-color: var(--accent, #7aa2f7); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); } +.tnl-list--gallery .tnl-row--gallery { + width: 100%; +} + @container tnl-main (max-width: 900px) { .tnl-row-gallery { flex-direction: column; height: auto; max-height: none; + align-items: stretch; } - .tnl-summary-card { - flex: 0 0 auto; + .tnl-row-gallery { + --tnl-summary-card-width: min(100%, 320px); + } + + .tnl-row-gallery-detail, + .tnl-row-gallery--chart-expanded .tnl-row-gallery-detail { + flex: 0 0 var(--tnl-summary-card-width); + width: var(--tnl-summary-card-width); + min-width: var(--tnl-summary-card-width); + max-width: var(--tnl-summary-card-width); + } + + .tnl-row-gallery-charts, + .tnl-row-gallery--chart-expanded .tnl-row-gallery-charts { + flex: 1 1 auto; width: 100%; max-width: 100%; - max-height: none; + min-width: 0; } - .tnl-charts-scroll, - .tnl-charts-expanded { + .tnl-hscroll-pane { height: 300px; max-height: 300px; } + + .tnl-row-gallery-charts .tnl-hscroll-pane__viewport { + overflow-x: auto; + } + + .tnl-summary-card { + min-width: 0; + width: 100%; + height: 100%; + max-height: none; + } + + .tnl-charts-track { + min-width: max-content; + width: max-content; + } + + .tnl-charts-track > .tnl-chart-card, + .tnl-charts-track > .tnl-signal-slot { + flex: 0 0 min(300px, 88vw); + width: min(300px, 88vw); + min-width: min(300px, 88vw); + max-width: none; + } } diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 1d27b61..56e3e40 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1279,6 +1279,19 @@ flex-shrink: 0; } +.vtd-card-head-quote { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.vtd-card--head-inline-quote .vtd-card-head-quote .vtd-target-quote--compact { + margin-top: 0; + padding: 0; + gap: 6px; +} + /* 카드 내 신호 / 차트 전환 */ .vtd-card-mode-toggle { display: inline-flex; @@ -1658,6 +1671,15 @@ max-width: 96px; } +.vtd-card-foot-select--readonly { + display: inline-block; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 1; +} + .vtd-sig-panel-title { font-size: 10px; font-weight: 800; @@ -1668,63 +1690,121 @@ } .vtd-sig-visual { + --vtd-sig-rail-height: 164px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; - align-items: start; - gap: 12px 14px; + align-items: center; + gap: 10px 12px; padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border)); + border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 38%, var(--se-border, var(--border))); border-radius: 10px; - background: var(--vtd-sig-panel-bg); + background: var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent)); +} + +/* 이퀄라이저 · 지표일치율 · 신호등 — 열별 pane 아웃라인 (가상매매·알림 동일) */ +.vtd-sig-visual-pane { + display: flex; + flex-direction: column; + align-self: stretch; + min-height: 0; + min-width: 0; + padding: 6px 8px; + border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 32%, var(--se-border, var(--border))); + border-radius: 8px; + background: color-mix(in srgb, var(--vtd-sig-panel-bg, rgba(13, 17, 31, 0.6)) 72%, transparent); + box-sizing: border-box; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 8%, transparent); } .vtd-sig-visual-eq { - display: flex; - align-items: stretch; flex-shrink: 0; + justify-content: stretch; + align-self: center; + height: var(--vtd-sig-rail-height); + min-height: var(--vtd-sig-rail-height); + max-height: var(--vtd-sig-rail-height); + box-sizing: border-box; } -/* 세그먼트 이퀄라이저 */ -.vtd-sig-eq { - display: flex; - gap: 8px; - align-items: stretch; - flex-shrink: 0; +.vtd-sig-visual-heat { + justify-content: center; + overflow: hidden; + align-self: center; + min-height: var(--vtd-sig-rail-height); } -.vtd-sig-eq-column { +.vtd-sig-visual-heat .vtd-heat-list { + width: 100%; + max-height: min(188px, calc(var(--vtd-sig-rail-height) + 48px)); + margin: auto 0; + overflow-y: auto; +} + +.vtd-sig-visual-light { + flex-shrink: 0; + align-items: center; + justify-content: flex-start; + align-self: center; +} + +.vtd-sig-light-rail { display: flex; flex-direction: column; align-items: center; + width: 100%; +} + +/* 세그먼트 이퀄라이저 — 신호등+하단문구 높이와 눈금·막대 동기화 */ +.vtd-sig-visual-eq .vtd-sig-eq { + display: flex; + gap: 8px; + align-items: stretch; flex: 1; - min-width: 40px; - max-width: 52px; + min-height: 0; + height: 100%; + width: 100%; } .vtd-sig-eq-scale { display: flex; flex-direction: column; justify-content: space-between; + flex: 0 0 auto; + height: 100%; font-size: 8px; color: var(--se-text-muted); padding: 2px 0; line-height: 1; } +.vtd-sig-eq-tick { + flex: 0 0 auto; + white-space: nowrap; +} + .vtd-sig-eq-bar-wrap { position: relative; - width: 100%; + flex: 1; + min-width: 40px; + max-width: 52px; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; } .vtd-sig-eq-bar { display: flex; flex-direction: column-reverse; gap: 2px; - height: 180px; + flex: 1; + min-height: 0; + height: 100%; padding: 4px; border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border)); border-radius: 6px; background: var(--vtd-sig-eq-bg); + box-sizing: border-box; } .vtd-sig-eq-seg { @@ -1756,7 +1836,10 @@ } .vtd-sig-eq-badge { - margin-top: 6px; + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%); font-size: 8px; font-weight: 800; letter-spacing: 0.04em; @@ -1765,6 +1848,7 @@ text-align: center; line-height: 1.2; text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent); + pointer-events: none; } /* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */ @@ -1941,8 +2025,8 @@ align-self: center; } -.vtd-sig-panel--summary .vtd-heat-list { - max-height: 160px; +.vtd-sig-panel--summary .vtd-sig-visual-heat .vtd-heat-list { + max-height: 100%; } /* legacy 조건 목록 (상세 테이블 STATUS 열) */ @@ -2047,13 +2131,18 @@ justify-content: center; } +.vtd-sig-visual-light .vtd-sig-light--card-right { + width: 100%; + height: auto; +} + .vtd-sig-light--card-right { flex: 0 0 auto; flex-direction: column; align-items: center; justify-content: flex-start; gap: 10px; - padding: 4px 0 2px; + padding: 0; } .vtd-sig-light-housing { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index e135a2d..1067835 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -25,7 +25,14 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { resolveBbBandBackground } from './bollingerConfig'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types'; import { formatChartAxisPrice } from './dataGenerator'; -import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone'; +import { + DEFAULT_CHART_TIME_FORMAT, + formatUnixWithChartPattern, + formatLwcTimeWithPattern, + formatLwcTickMarkWithPattern, + normalizeChartTimeFormat, +} from './chartTimeFormat'; +import { DEFAULT_DISPLAY_TIMEZONE } from './timezone'; import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge'; import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry'; import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin'; @@ -203,6 +210,7 @@ export class ChartManager { private currentChartType: ChartType = 'candlestick'; private displayTimezone = DEFAULT_DISPLAY_TIMEZONE; private displayTimeframe: Timeframe = '1D'; + private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT; private mainMarkersPlugin: ISeriesMarkersPluginApi