From 4f6694b206cefbcfdd861329fa5409300a157694 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 28 May 2026 00:36:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=B4=89=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StrategyConditionTimeframeService.java | 46 ++++- ...StrategyConditionTimeframeServiceTest.java | 32 ++++ frontend/src/App.tsx | 13 +- frontend/src/components/ChartSlot.tsx | 11 +- .../components/IndicatorSettingsSections.tsx | 4 +- .../IndicatorSettingsStyleSection.tsx | 4 +- frontend/src/components/PaneLegend.tsx | 158 ++++++++---------- .../src/components/StrategyEditorPage.tsx | 42 ++++- frontend/src/components/TradingChart.tsx | 2 +- frontend/src/hooks/useIndicatorSettings.ts | 33 +++- frontend/src/utils/ChartManager.ts | 11 +- frontend/src/utils/backendApi.ts | 6 +- frontend/src/utils/indicatorPaneMerge.ts | 5 +- frontend/src/utils/indicatorSettingsEditor.ts | 3 +- frontend/src/utils/plotColorUtils.ts | 17 +- frontend/src/utils/strategyConditionSerde.ts | 25 +++ 16 files changed, 285 insertions(+), 127 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 984d10a..de64e40 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -68,8 +68,20 @@ public class StrategyConditionTimeframeService { ensureDslRepaired(strategy); Set out = new LinkedHashSet<>(); - collectFromJson(strategy.getBuyConditionJson(), out, strategy.getName()); - collectFromJson(strategy.getSellConditionJson(), out, strategy.getName()); + boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), out); + boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), out); + + if (!out.isEmpty()) { + return out; + } + + // 레거시: START(TIMEFRAME) 래핑 없는 DSL + if (!buyScoped) { + collectLegacyFromJson(strategy.getBuyConditionJson(), out, strategy.getName()); + } + if (!sellScoped) { + collectLegacyFromJson(strategy.getSellConditionJson(), out, strategy.getName()); + } if (out.isEmpty()) { String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); @@ -106,19 +118,39 @@ public class StrategyConditionTimeframeService { } } - private void collectFromJson(String json, Set out, String strategyName) { - if (json == null || json.isBlank()) return; + /** + * START 루트 TIMEFRAME(candleTypes)만 수집 — 조건 노드 leftCandleType·내부 1m 기본값은 제외. + * + * @return JSON 에 START 스코프가 있으면 true (빈 조건이어도) + */ + private boolean collectStartScopeFromJson(String json, Set out) { + if (json == null || json.isBlank()) return false; try { - collectFromNode(objectMapper.readTree(json), out, strategyName); + return collectStartScopeFromNode(objectMapper.readTree(json), out); } catch (Exception e) { log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); + return false; + } + } + + private boolean collectStartScopeFromNode(JsonNode node, Set out) { + if (node == null || node.isNull()) return false; + return collectTimeframesFromNode(node, out); + } + + /** 레거시 DSL — START 래핑 없을 때만 조건 트리·전략명에서 추론 */ + private void collectLegacyFromJson(String json, Set out, String strategyName) { + if (json == null || json.isBlank()) return; + try { + collectLegacyFromNode(objectMapper.readTree(json), out, strategyName); + } catch (Exception e) { + log.warn("[StrategyTimeframes] legacy JSON 파싱 실패: {}", e.getMessage()); addFallbackTimeframe(out, strategyName); } } - private void collectFromNode(JsonNode node, Set out, String strategyName) { + private void collectLegacyFromNode(JsonNode node, Set out, String strategyName) { if (node == null || node.isNull()) return; - if (collectTimeframesFromNode(node, out)) return; Set meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node); if (!meaningful.isEmpty()) { diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index cce3521..b0dbe6c 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -101,6 +101,38 @@ class StrategyConditionTimeframeServiceTest { assertTrue(service.usesTimeframe(3L, "3m")); } + @Test + void collectForStrategy_buyStartScopeIgnoresSellLegacy1m() { + GcStrategy mixed = new GcStrategy(); + mixed.setId(5L); + mixed.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "3m", + "candleTypes": ["3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "STOCHASTIC", "period": 14 } + }] + } + """); + mixed.setSellConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "1m", + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "STOCHASTIC", "period": 14 } + }] + } + """); + when(strategyRepo.findById(5L)).thenReturn(Optional.of(mixed)); + + Set tfs = service.collectForStrategy(5L); + assertEquals(Set.of("3m", "5m"), tfs); + assertFalse(service.usesTimeframe(5L, "1m")); + } + @Test void collectForStrategy_legacyTreeDefaultsTo1m() { GcStrategy legacy = new GcStrategy(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4092fea..ddc9968 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1361,7 +1361,7 @@ function App() { cfg = { ...fromConfig, id: newIndId(), type: def.type }; } else { const params = getParams(type, def.defaultParams); - const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines); cfg = { id: newIndId(), type: def.type, @@ -1369,6 +1369,7 @@ function App() { plots, hlines, ...(cloudColors ? { cloudColors } : {}), + ...(bandBackground ? { bandBackground } : {}), }; } if (type === 'SMA') { @@ -1463,7 +1464,13 @@ function App() { isIndicatorSettingsTemplateId(settingsModalId ?? '') || updated.id.startsWith('template_'); saveParams(updated.type, updated.params); - saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); + saveVisual( + updated.type, + updated.plots, + updated.hlines, + updated.cloudColors, + updated.bandBackground, + ); const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => { if (fromTemplate) { @@ -1539,7 +1546,7 @@ function App() { applyChartIndicators(chartIndicators); allConfigs.forEach(ind => { saveParams(ind.type, ind.params); - saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors); + saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground); }); setShowBulkIndSettings(false); }, [applyChartIndicators, saveParams, saveVisual]); diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 05fc7b3..af18156 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -260,7 +260,7 @@ const ChartSlot = forwardRef(function ChartSlot cfg = { ...fromConfig, id: newIndId(), type: def.type }; } else { const params = getParams(type, def.defaultParams); - const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines); cfg = { id: newIndId(), type: def.type, @@ -268,6 +268,7 @@ const ChartSlot = forwardRef(function ChartSlot plots, hlines, ...(cloudColors ? { cloudColors } : {}), + ...(bandBackground ? { bandBackground } : {}), }; } if (type === 'SMA') { @@ -566,7 +567,13 @@ const ChartSlot = forwardRef(function ChartSlot // 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영 saveParams(updated.type, updated.params); // 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용 - saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); + saveVisual( + updated.type, + updated.plots, + updated.hlines, + updated.cloudColors, + updated.bandBackground, + ); }, [saveParams, saveVisual]); const handleToggleHidden = useCallback((id: string) => { diff --git a/frontend/src/components/IndicatorSettingsSections.tsx b/frontend/src/components/IndicatorSettingsSections.tsx index 47bce0a..8c1b4aa 100644 --- a/frontend/src/components/IndicatorSettingsSections.tsx +++ b/frontend/src/components/IndicatorSettingsSections.tsx @@ -191,7 +191,7 @@ export const PlotSettingsRow: React.FC<{ isHistogram && indicatorType === 'MACD' ? (
- 상승 + 0선 위
- 하락 + 0선 아래
- 상승 막대 + 0선 위 (양수)
색상 @@ -225,7 +225,7 @@ const IndicatorSettingsStyleSection: React.FC
- 하락 막대 + 0선 아래 (음수)
색상 diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index f173d6e..be58813 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -69,11 +69,6 @@ function getDomSubIndicatorLayouts( return subs; } -/** 보조지표 1개 = 1 화면 슬롯 (병합 시 여러 item 이 한 슬롯) */ -interface VisualSlot { - items: PaneItem[]; -} - interface PaneCapture { dataUrl: string; cssWidth: number; @@ -220,80 +215,71 @@ function makeLabel(config: IndicatorConfig): string { return nums.length ? `${name} ${nums.join(', ')}` : name; } -function buildVisualSlots(items: PaneItem[], indicators: IndicatorConfig[]): VisualSlot[] { - const slots: VisualSlot[] = []; - const hostSlotIdx = new Map(); - - for (const item of items) { - const ind = indicators.find(i => i.id === item.id); - if (ind?.mergedWith) { - const hostId = getPaneHostId(item.id, indicators); - const idx = hostSlotIdx.get(hostId); - if (idx !== undefined) slots[idx].items.push(item); - continue; - } - const idx = slots.length; - slots.push({ items: [item] }); - hostSlotIdx.set(item.id, idx); - hostSlotIdx.set(getPaneHostId(item.id, indicators), idx); - } - return slots; +/** paneIndex ↔ LWC pane DOM 위치 (배열 순서·orphan pane 과 무관) */ +function layoutForPaneIndex( + paneIndex: number, + layouts: Array<{ paneIndex: number; topY: number; height: number }>, +): { topY: number; height: number } | null { + if (paneIndex < 2) return null; + const lay = layouts.find(l => l.paneIndex === paneIndex && l.height > MIN_SUB_PANE_HEIGHT); + return lay ? { topY: lay.topY, height: lay.height } : null; } -/** 각 슬롯(호스트 paneIndex) ↔ LWC pane 레이아웃 직접 매칭 — 배열 순서·orphan pane 무관 */ -function assignLayoutsByPaneIndex( - slots: VisualSlot[], +function applyPaneLayoutToItem( + item: PaneItem, + layout: { topY: number; height: number }, + overwrite = false, +): void { + if (!overwrite && item.layoutTopY != null) return; + item.layoutTopY = layout.topY; + item.layoutHeight = layout.height; +} + +/** paneIndex 기준으로만 좌표 보강 (지표 배열 순서와 화면 pane 순서가 달라도 안전) */ +function fillMissingLayoutsByPaneIndex( + items: PaneItem[], layouts: Array<{ paneIndex: number; topY: number; height: number }>, ): void { - const byPane = new Map(layouts.map(l => [l.paneIndex, l])); - for (const slot of slots) { - const host = slot.items[0]; - if (!host || host.paneIndex < 2) continue; - 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; - } + for (const item of items) { + if (item.layoutTopY != null || item.paneIndex < 2) continue; + const lay = layoutForPaneIndex(item.paneIndex, layouts); + if (lay) applyPaneLayoutToItem(item, lay); } } -/** 화면 순서(위→아래)의 활성 sub-pane ↔ 슬롯 순서 — orphan pane index 와 분리 */ -function assignLayoutsBySubPaneOrder( - slots: VisualSlot[], - subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, +/** DOM 행 중 paneIndex 레이아웃 topY 와 가장 가까운 행 선택 */ +function fillMissingLayoutsFromDom( + items: PaneItem[], + containerEl: HTMLElement, + paneLayouts: 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; - } - } -} + const missing = items.filter(i => i.layoutTopY == null && i.paneIndex >= 2); + if (missing.length === 0) return; -/** 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; + const domSubs = getDomSubIndicatorLayouts(containerEl, missing.length); + if (domSubs.length === 0) return; + + const usedDom = new Set(); + const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex); + + for (const item of sorted) { + const ref = layoutForPaneIndex(item.paneIndex, paneLayouts); + let bestIdx = -1; + let bestDist = Infinity; + for (let i = 0; i < domSubs.length; i++) { + if (usedDom.has(i)) continue; + const dist = ref + ? Math.abs(domSubs[i].topY - ref.topY) + : domSubs[i].topY; + if (dist < bestDist) { + bestDist = dist; + bestIdx = i; } } + if (bestIdx < 0) continue; + if (ref && bestDist > 96) continue; + usedDom.add(bestIdx); + applyPaneLayoutToItem(item, domSubs[bestIdx]); } } @@ -333,36 +319,24 @@ function buildPaneItems( for (const item of items) { const lay = manager.getIndicatorPaneScreenLayout(item.id); - if (lay) { - item.layoutTopY = lay.topY; - item.layoutHeight = lay.height; - } + if (lay) applyPaneLayoutToItem(item, lay, true); } - const slots = buildVisualSlots(items, indicators); - const subLayouts = manager.getIndicatorSubPaneLayouts(); - assignLayoutsBySubPaneOrder(slots, subLayouts); - assignLayoutsByPaneIndex(slots, manager.getPaneLayouts()); - if (subLayouts.length > 0) { - reconcileLayoutsWithSubPanes(slots, subLayouts); - } + const paneLayouts = manager.getPaneLayouts(); + fillMissingLayoutsByPaneIndex(items, paneLayouts); + fillMissingLayoutsByPaneIndex(items, manager.getIndicatorSubPaneLayouts()); if (containerEl?.isConnected) { - const missing = items.filter(i => i.layoutTopY == null); - if (missing.length > 0) { - const domSubs = getDomSubIndicatorLayouts(containerEl, slots.length); - for (let i = 0; i < slots.length && i < domSubs.length; i++) { - if (slots[i].items.every(it => it.layoutTopY != null)) continue; - const dom = domSubs[i]; - for (const item of slots[i].items) { - if (item.layoutTopY != null) continue; - item.layoutTopY = dom.topY; - item.layoutHeight = dom.height; - } - } - } + fillMissingLayoutsFromDom(items, containerEl, paneLayouts); } + items.sort((a, b) => { + const ay = a.layoutTopY ?? 1e9; + const by = b.layoutTopY ?? 1e9; + if (ay !== by) return ay - by; + return a.paneIndex - b.paneIndex; + }); + return items; } diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 141231e..0e51c5e 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -23,7 +23,9 @@ import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout'; import { decodeConditionForEditor, + alignBuySellStartCandleTypesForSave, encodeConditionForSave, + syncBuySellPrimaryStartCandleTypes, mergeStartMetaForLoad, addExtraStartSection, hasMultipleStartSections, @@ -33,6 +35,7 @@ import { type EditorConditionState, } from '../utils/strategyConditionSerde'; import { + START_NODE_ID, defaultStartMeta, type StartCombineOp, } from '../utils/strategyStartNodes'; @@ -393,9 +396,36 @@ export default function StrategyEditorPage({ theme }: Props) { }, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { + if (startId === START_NODE_ID) { + const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes); + setBuyCondition(synced.buy.root); + setBuyLayout(prev => ({ + ...prev, + startMeta: synced.buy.startMeta, + extraStartIds: synced.buy.extraStartIds, + extraRoots: synced.buy.extraRoots, + startCombineOp: normalizeStartCombineOp(synced.buy.startCombineOp), + })); + setSellCondition(synced.sell.root); + setSellLayout(prev => ({ + ...prev, + startMeta: synced.sell.startMeta, + extraStartIds: synced.sell.extraStartIds, + extraRoots: synced.sell.extraRoots, + startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp), + })); + schedulePersistFlowLayout(layoutStrategyKey); + return; + } const state = signalTab === 'buy' ? buyEditorState : sellEditorState; handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes)); - }, [signalTab, buyEditorState, sellEditorState, handleEditorStateChange]); + }, [ + buyEditorState, + sellEditorState, + handleEditorStateChange, + layoutStrategyKey, + schedulePersistFlowLayout, + ]); const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); @@ -559,8 +589,9 @@ export default function StrategyEditorPage({ theme }: Props) { } setSaveNameError(false); if (editorMode === 'graph') layoutFlushRef.current?.(); - const encodedBuy = encodeConditionForSave(buyEditorState); - const encodedSell = encodeConditionForSave(sellEditorState); + const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState); + const encodedBuy = encodeConditionForSave(aligned.buy); + const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; } setIsSaving(true); try { @@ -642,8 +673,9 @@ export default function StrategyEditorPage({ theme }: Props) { }, [resetFlowLayout, bumpLayoutSeed, signalTab]); const handleExport = useCallback(() => { - const encodedBuy = encodeConditionForSave(buyEditorState); - const encodedSell = encodeConditionForSave(sellEditorState); + const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState); + const encodedBuy = encodeConditionForSave(aligned.buy); + const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) { showSnack('내보낼 조건이 없습니다', false); return; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 96c1143..aaf48b8 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -1430,7 +1430,7 @@ function sortedParamKey(inds: IndicatorConfig[]): string { */ function styleKey(inds: IndicatorConfig[]): string { return inds.map(i => - `${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}` + `${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}|${JSON.stringify(i.hlinesBackground ?? {})}|${JSON.stringify(i.bandBackground ?? {})}` ).join(';'); } diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index 691f0f5..c7210f6 100644 --- a/frontend/src/hooks/useIndicatorSettings.ts +++ b/frontend/src/hooks/useIndicatorSettings.ts @@ -30,7 +30,8 @@ import { type IchimokuCloudColors, resolveIchimokuCloudColors, } from '../utils/ichimokuConfig'; -import type { IndicatorConfig } from '../types'; +import type { HlinesBackground, IndicatorConfig } from '../types'; +import { resolveBbBandBackground } from '../utils/bollingerConfig'; import { loadIndicatorSettings, saveIndicatorSettings, @@ -46,6 +47,16 @@ export interface IndicatorVisual { plots?: PlotDef[]; hlines?: HLineDef[]; cloudColors?: IchimokuCloudColors; + /** 볼린저밴드 어퍼~로우어 배경 (업비트 백그라운드 그리기) */ + bandBackground?: HlinesBackground; +} + +/** getVisualConfig 반환 타입 */ +export interface IndicatorVisualConfig { + plots: PlotDef[]; + hlines: HLineDef[]; + cloudColors?: IchimokuCloudColors; + bandBackground?: HlinesBackground; } type VisualCache = Record; @@ -204,12 +215,15 @@ export function useIndicatorSettings(sessionKey = 0) { hlines: cfg.hlines, }; if (cfg.cloudColors) visual.cloudColors = cfg.cloudColors; + if (cfg.type === 'BollingerBands' && cfg.bandBackground) { + visual.bandBackground = cfg.bandBackground; + } return { type: cfg.type, visual }; }); await Promise.all( visualEntries.map(({ type, visual }) => - saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }), + saveIndicatorVisualSettings(type, visual), ), ); @@ -238,8 +252,8 @@ export function useIndicatorSettings(sessionKey = 0) { ( type: string, defaultPlots?: PlotDef[], - defaultHlines?: HLineDef[] - ): { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors } => { + defaultHlines?: HLineDef[], + ): IndicatorVisualConfig => { const saved = _visualCache?.[type]; const def = getIndicatorDef(type); const registryPlots = defaultPlots ?? def?.plots ?? []; @@ -265,11 +279,16 @@ export function useIndicatorSettings(sessionKey = 0) { ? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS) : undefined; + const bandBackground = type === 'BollingerBands' + ? resolveBbBandBackground(saved?.bandBackground) + : undefined; + // 깊은 복사: 여러 슬롯이 같은 참조를 공유하지 않도록 return { plots: plots.map(p => ({ ...p })), hlines: hlines.map(h => ({ ...h })), cloudColors: cloudColors ? { ...cloudColors } : undefined, + bandBackground: bandBackground ? { ...bandBackground } : undefined, }; }, [] @@ -285,15 +304,19 @@ export function useIndicatorSettings(sessionKey = 0) { plots?: PlotDef[], hlines?: HLineDef[], cloudColors?: IchimokuCloudColors, + bandBackground?: HlinesBackground, ) => { const visual: IndicatorVisual = { plots, hlines }; if (cloudColors) visual.cloudColors = cloudColors; + if (type === 'BollingerBands') { + visual.bandBackground = bandBackground ?? resolveBbBandBackground(_visualCache?.[type]?.bandBackground); + } if (_visualCache) { _visualCache = { ..._visualCache, [type]: visual }; } else { _visualCache = { [type]: visual }; } - saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err => + saveIndicatorVisualSettings(type, visual).catch(err => console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err) ); notifyIndicatorSettingsChanged(); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index a1711d5..580d338 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -147,11 +147,20 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean { } function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void { + if (!shouldShowBbBandFill(config)) { + detachBbBandFill(entry); + return; + } const basis = seriesByPlotId(entry, 'plot0'); const upper = seriesByPlotId(entry, 'plot1'); const lower = seriesByPlotId(entry, 'plot2'); - if (!basis || !upper || !lower || !shouldShowBbBandFill(config)) return; + if (!basis || !upper || !lower) return; const bg = resolveBbBandBackground(config.bandBackground); + if (entry.bbFillPrimitive) { + entry.bbFillPrimitive.updateBackground(bg); + entry.bbFillPrimitive.requestRefresh(); + return; + } entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg); basis.attachPrimitive(entry.bbFillPrimitive); } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 4a3cd63..f6147e7 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -329,10 +329,10 @@ export async function saveIndicatorSettings( * 앱 시작 시 호출 — 전역 시각 기본값을 DB 에서 가져온다. */ export async function loadIndicatorVisualSettings(): Promise< - Record + Record > { return ( - (await request>( + (await request>( '/indicator-settings/visual' )) ?? {} ); @@ -347,7 +347,7 @@ export async function loadIndicatorVisualSettings(): Promise< */ export async function saveIndicatorVisualSettings( indicatorType: string, - visual: { plots?: unknown[]; hlines?: unknown[] } + visual: { plots?: unknown[]; hlines?: unknown[]; cloudColors?: unknown; bandBackground?: unknown }, ): Promise { await request(`/indicator-settings/${encodeURIComponent(indicatorType)}/visual`, { method: 'PATCH', diff --git a/frontend/src/utils/indicatorPaneMerge.ts b/frontend/src/utils/indicatorPaneMerge.ts index 0dcc61c..4bfe4c8 100644 --- a/frontend/src/utils/indicatorPaneMerge.ts +++ b/frontend/src/utils/indicatorPaneMerge.ts @@ -151,7 +151,7 @@ type GetVisual = ( type: string, defaultPlots?: PlotDef[], defaultHlines?: HLineDef[], -) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; +) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors; bandBackground?: import('../types').HlinesBackground }; /** 탭 전체 추가 — 기존 차트 지표를 제거하고 types 목록만 새로 구성 */ export function replaceIndicatorConfigsFromTypes( @@ -181,7 +181,7 @@ export function buildIndicatorConfigsFromTypes( seen.add(type); const params = getParams(type, def.defaultParams); - const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines); let cfg: IndicatorConfig = { id: newId(), type: def.type, @@ -189,6 +189,7 @@ export function buildIndicatorConfigsFromTypes( plots, hlines, ...(cloudColors ? { cloudColors } : {}), + ...(bandBackground ? { bandBackground } : {}), }; if (type === 'SMA') { cfg = normalizeSmaConfig({ diff --git a/frontend/src/utils/indicatorSettingsEditor.ts b/frontend/src/utils/indicatorSettingsEditor.ts index 8d09d73..2525f84 100644 --- a/frontend/src/utils/indicatorSettingsEditor.ts +++ b/frontend/src/utils/indicatorSettingsEditor.ts @@ -54,7 +54,7 @@ export function initializeIndicatorConfigForEditor( type: string, defaultPlots: PlotDef[], defaultHlines?: HLineDef[], - ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }, + ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors; bandBackground?: HlinesBackground }, ): IndicatorConfig { const def = getIndicatorDef(raw.type); if (!def) return enrichIndicatorConfig(raw); @@ -70,6 +70,7 @@ export function initializeIndicatorConfigForEditor( plots: raw.plots?.length ? raw.plots : visual?.plots, hlines: raw.hlines?.length ? raw.hlines : visual?.hlines, cloudColors: raw.cloudColors ?? visual?.cloudColors, + bandBackground: raw.bandBackground ?? visual?.bandBackground, }); } diff --git a/frontend/src/utils/plotColorUtils.ts b/frontend/src/utils/plotColorUtils.ts index 4c63a07..e139889 100644 --- a/frontend/src/utils/plotColorUtils.ts +++ b/frontend/src/utils/plotColorUtils.ts @@ -68,12 +68,27 @@ export function resolveHistogramDownColor(plot: PlotDef): string { return plot.histogramDownColor ?? DEFAULT_HIST_DOWN; } -/** MACD 등 histogram 막대 색 (상승·하락/음수 구분) */ +/** MACD 히스토그램 — histogramUp/DownColor 로 0선 위·아래 색 분리 */ +export function isMacdDualHistogramPlot(plot: PlotDef): boolean { + return plot.type === 'histogram' + && plot.histogramUpColor != null + && plot.histogramDownColor != null; +} + +/** + * histogram 막대 색. + * - MACD(상·하 색 지정): 0선 기준 — 양수=상승색, 음수=하락색 (전봴 대비 혼합 없음) + * - 기타: 전봴 대비 방향 + 음수 영역 하락색 + */ export function histogramBarColor( value: number, prev: number | null | undefined, plot: PlotDef, ): string { + if (isMacdDualHistogramPlot(plot)) { + const raw = value < 0 ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot); + return withHistogramAlpha(raw); + } const isDown = prev != null && !isNaN(prev) && value < prev; const isNeg = value < 0; const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot); diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index 235542d..4495723 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -399,6 +399,31 @@ export function collectTimeframesFromEditorState(state: EditorConditionState): s return [...set]; } +/** 매수·매도 START(기본) 평가 분봉을 동일하게 맞춤 — 한쪽만 3m·5m 체크 시 다른 쪽 1m 잔존 방지 */ +export function syncBuySellPrimaryStartCandleTypes( + buy: EditorConditionState, + sell: EditorConditionState, + candleTypes: string[], +): { buy: EditorConditionState; sell: EditorConditionState } { + const types = normalizeCandleTypesList(candleTypes); + return { + buy: updateStartCandleTypes(buy, START_NODE_ID, types), + sell: updateStartCandleTypes(sell, START_NODE_ID, types), + }; +} + +/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */ +export function alignBuySellStartCandleTypesForSave( + buy: EditorConditionState, + sell: EditorConditionState, +): { buy: EditorConditionState; sell: EditorConditionState } { + const merged = normalizeCandleTypesList([ + ...getStartCandleTypes(buy.startMeta[START_NODE_ID]), + ...getStartCandleTypes(sell.startMeta[START_NODE_ID]), + ]); + return syncBuySellPrimaryStartCandleTypes(buy, sell, merged); +} + /** 저장된 DSL에서 Logic Expression용 분기 목록 */ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { return collectEditorBranches(decodeConditionForEditor(dsl));