전략 시간봉 오류 수정

This commit is contained in:
Macbook
2026-05-28 00:36:49 +09:00
parent 2713b2951d
commit 4f6694b206
16 changed files with 285 additions and 127 deletions
@@ -68,8 +68,20 @@ public class StrategyConditionTimeframeService {
ensureDslRepaired(strategy); ensureDslRepaired(strategy);
Set<String> out = new LinkedHashSet<>(); Set<String> out = new LinkedHashSet<>();
collectFromJson(strategy.getBuyConditionJson(), out, strategy.getName()); boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), out);
collectFromJson(strategy.getSellConditionJson(), out, strategy.getName()); 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()) { if (out.isEmpty()) {
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
@@ -106,19 +118,39 @@ public class StrategyConditionTimeframeService {
} }
} }
private void collectFromJson(String json, Set<String> out, String strategyName) { /**
if (json == null || json.isBlank()) return; * START 루트 TIMEFRAME(candleTypes)만 수집 — 조건 노드 leftCandleType·내부 1m 기본값은 제외.
*
* @return JSON 에 START 스코프가 있으면 true (빈 조건이어도)
*/
private boolean collectStartScopeFromJson(String json, Set<String> out) {
if (json == null || json.isBlank()) return false;
try { try {
collectFromNode(objectMapper.readTree(json), out, strategyName); return collectStartScopeFromNode(objectMapper.readTree(json), out);
} catch (Exception e) { } catch (Exception e) {
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
return false;
}
}
private boolean collectStartScopeFromNode(JsonNode node, Set<String> out) {
if (node == null || node.isNull()) return false;
return collectTimeframesFromNode(node, out);
}
/** 레거시 DSL — START 래핑 없을 때만 조건 트리·전략명에서 추론 */
private void collectLegacyFromJson(String json, Set<String> 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); addFallbackTimeframe(out, strategyName);
} }
} }
private void collectFromNode(JsonNode node, Set<String> out, String strategyName) { private void collectLegacyFromNode(JsonNode node, Set<String> out, String strategyName) {
if (node == null || node.isNull()) return; if (node == null || node.isNull()) return;
if (collectTimeframesFromNode(node, out)) return;
Set<String> meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node); Set<String> meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node);
if (!meaningful.isEmpty()) { if (!meaningful.isEmpty()) {
@@ -101,6 +101,38 @@ class StrategyConditionTimeframeServiceTest {
assertTrue(service.usesTimeframe(3L, "3m")); 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<String> tfs = service.collectForStrategy(5L);
assertEquals(Set.of("3m", "5m"), tfs);
assertFalse(service.usesTimeframe(5L, "1m"));
}
@Test @Test
void collectForStrategy_legacyTreeDefaultsTo1m() { void collectForStrategy_legacyTreeDefaultsTo1m() {
GcStrategy legacy = new GcStrategy(); GcStrategy legacy = new GcStrategy();
+10 -3
View File
@@ -1361,7 +1361,7 @@ function App() {
cfg = { ...fromConfig, id: newIndId(), type: def.type }; cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else { } else {
const params = getParams(type, def.defaultParams); 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 = { cfg = {
id: newIndId(), id: newIndId(),
type: def.type, type: def.type,
@@ -1369,6 +1369,7 @@ function App() {
plots, plots,
hlines, hlines,
...(cloudColors ? { cloudColors } : {}), ...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
}; };
} }
if (type === 'SMA') { if (type === 'SMA') {
@@ -1463,7 +1464,13 @@ function App() {
isIndicatorSettingsTemplateId(settingsModalId ?? '') isIndicatorSettingsTemplateId(settingsModalId ?? '')
|| updated.id.startsWith('template_'); || updated.id.startsWith('template_');
saveParams(updated.type, updated.params); 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[] => { const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => {
if (fromTemplate) { if (fromTemplate) {
@@ -1539,7 +1546,7 @@ function App() {
applyChartIndicators(chartIndicators); applyChartIndicators(chartIndicators);
allConfigs.forEach(ind => { allConfigs.forEach(ind => {
saveParams(ind.type, ind.params); 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); setShowBulkIndSettings(false);
}, [applyChartIndicators, saveParams, saveVisual]); }, [applyChartIndicators, saveParams, saveVisual]);
+9 -2
View File
@@ -260,7 +260,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
cfg = { ...fromConfig, id: newIndId(), type: def.type }; cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else { } else {
const params = getParams(type, def.defaultParams); 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 = { cfg = {
id: newIndId(), id: newIndId(),
type: def.type, type: def.type,
@@ -268,6 +268,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
plots, plots,
hlines, hlines,
...(cloudColors ? { cloudColors } : {}), ...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
}; };
} }
if (type === 'SMA') { if (type === 'SMA') {
@@ -566,7 +567,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영 // 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
saveParams(updated.type, updated.params); saveParams(updated.type, updated.params);
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용 // 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); saveVisual(
updated.type,
updated.plots,
updated.hlines,
updated.cloudColors,
updated.bandBackground,
);
}, [saveParams, saveVisual]); }, [saveParams, saveVisual]);
const handleToggleHidden = useCallback((id: string) => { const handleToggleHidden = useCallback((id: string) => {
@@ -191,7 +191,7 @@ export const PlotSettingsRow: React.FC<{
isHistogram && indicatorType === 'MACD' ? ( isHistogram && indicatorType === 'MACD' ? (
<div className="ism-hist-colors"> <div className="ism-hist-colors">
<div className="ism-hist-color-field"> <div className="ism-hist-color-field">
<span className="ism-style-label"></span> <span className="ism-style-label">0 </span>
<ColorInput <ColorInput
value={resolveHistogramUpColor(plot)} value={resolveHistogramUpColor(plot)}
disabled={!enabled} disabled={!enabled}
@@ -199,7 +199,7 @@ export const PlotSettingsRow: React.FC<{
/> />
</div> </div>
<div className="ism-hist-color-field"> <div className="ism-hist-color-field">
<span className="ism-style-label"></span> <span className="ism-style-label">0 </span>
<ColorInput <ColorInput
value={resolveHistogramDownColor(plot)} value={resolveHistogramDownColor(plot)}
disabled={!enabled} disabled={!enabled}
@@ -213,7 +213,7 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
</div> </div>
<div className="ism-style-row"> <div className="ism-style-row">
<div className="ism-plot-title-cell"> <div className="ism-plot-title-cell">
<span className="ism-plot-title ism-plot-title--sub"> </span> <span className="ism-plot-title ism-plot-title--sub">0 ()</span>
</div> </div>
<div className="ism-style-field ism-style-field--wide"> <div className="ism-style-field ism-style-field--wide">
<span className="ism-style-label"></span> <span className="ism-style-label"></span>
@@ -225,7 +225,7 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
</div> </div>
<div className="ism-style-row"> <div className="ism-style-row">
<div className="ism-plot-title-cell"> <div className="ism-plot-title-cell">
<span className="ism-plot-title ism-plot-title--sub"> </span> <span className="ism-plot-title ism-plot-title--sub">0 ()</span>
</div> </div>
<div className="ism-style-field ism-style-field--wide"> <div className="ism-style-field ism-style-field--wide">
<span className="ism-style-label"></span> <span className="ism-style-label"></span>
+66 -92
View File
@@ -69,11 +69,6 @@ function getDomSubIndicatorLayouts(
return subs; return subs;
} }
/** 보조지표 1개 = 1 화면 슬롯 (병합 시 여러 item 이 한 슬롯) */
interface VisualSlot {
items: PaneItem[];
}
interface PaneCapture { interface PaneCapture {
dataUrl: string; dataUrl: string;
cssWidth: number; cssWidth: number;
@@ -220,80 +215,71 @@ function makeLabel(config: IndicatorConfig): string {
return nums.length ? `${name} ${nums.join(', ')}` : name; return nums.length ? `${name} ${nums.join(', ')}` : name;
} }
function buildVisualSlots(items: PaneItem[], indicators: IndicatorConfig[]): VisualSlot[] { /** paneIndex ↔ LWC pane DOM 위치 (배열 순서·orphan pane 과 무관) */
const slots: VisualSlot[] = []; function layoutForPaneIndex(
const hostSlotIdx = new Map<string, number>(); paneIndex: number,
layouts: Array<{ paneIndex: number; topY: number; height: number }>,
for (const item of items) { ): { topY: number; height: number } | null {
const ind = indicators.find(i => i.id === item.id); if (paneIndex < 2) return null;
if (ind?.mergedWith) { const lay = layouts.find(l => l.paneIndex === paneIndex && l.height > MIN_SUB_PANE_HEIGHT);
const hostId = getPaneHostId(item.id, indicators); return lay ? { topY: lay.topY, height: lay.height } : null;
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 레이아웃 직접 매칭 — 배열 순서·orphan pane 무관 */ function applyPaneLayoutToItem(
function assignLayoutsByPaneIndex( item: PaneItem,
slots: VisualSlot[], 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 }>, layouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void { ): void {
const byPane = new Map(layouts.map(l => [l.paneIndex, l])); for (const item of items) {
for (const slot of slots) { if (item.layoutTopY != null || item.paneIndex < 2) continue;
const host = slot.items[0]; const lay = layoutForPaneIndex(item.paneIndex, layouts);
if (!host || host.paneIndex < 2) continue; if (lay) applyPaneLayoutToItem(item, lay);
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 와 분리 */ /** DOM 행 중 paneIndex 레이아웃 topY 와 가장 가까운 행 선택 */
function assignLayoutsBySubPaneOrder( function fillMissingLayoutsFromDom(
slots: VisualSlot[], items: PaneItem[],
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, containerEl: HTMLElement,
paneLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void { ): void {
for (let i = 0; i < slots.length && i < subLayouts.length; i++) { const missing = items.filter(i => i.layoutTopY == null && i.paneIndex >= 2);
const lay = subLayouts[i]; if (missing.length === 0) return;
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 위치로 보정 */ const domSubs = getDomSubIndicatorLayouts(containerEl, missing.length);
function reconcileLayoutsWithSubPanes( if (domSubs.length === 0) return;
slots: VisualSlot[],
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, const usedDom = new Set<number>();
): void { const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex);
const TOL = 24;
for (let i = 0; i < slots.length && i < subLayouts.length; i++) { for (const item of sorted) {
const expected = subLayouts[i]; const ref = layoutForPaneIndex(item.paneIndex, paneLayouts);
for (const item of slots[i].items) { let bestIdx = -1;
const cur = item.layoutTopY; let bestDist = Infinity;
const curH = item.layoutHeight; for (let i = 0; i < domSubs.length; i++) {
if ( if (usedDom.has(i)) continue;
cur == null const dist = ref
|| Math.abs(cur - expected.topY) > TOL ? Math.abs(domSubs[i].topY - ref.topY)
|| (curH != null && Math.abs(curH - expected.height) > TOL) : domSubs[i].topY;
) { if (dist < bestDist) {
item.layoutTopY = expected.topY; bestDist = dist;
item.layoutHeight = expected.height; 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) { for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id); const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) { if (lay) applyPaneLayoutToItem(item, lay, true);
item.layoutTopY = lay.topY;
item.layoutHeight = lay.height;
}
} }
const slots = buildVisualSlots(items, indicators); const paneLayouts = manager.getPaneLayouts();
const subLayouts = manager.getIndicatorSubPaneLayouts(); fillMissingLayoutsByPaneIndex(items, paneLayouts);
assignLayoutsBySubPaneOrder(slots, subLayouts); fillMissingLayoutsByPaneIndex(items, manager.getIndicatorSubPaneLayouts());
assignLayoutsByPaneIndex(slots, manager.getPaneLayouts());
if (subLayouts.length > 0) {
reconcileLayoutsWithSubPanes(slots, subLayouts);
}
if (containerEl?.isConnected) { if (containerEl?.isConnected) {
const missing = items.filter(i => i.layoutTopY == null); fillMissingLayoutsFromDom(items, containerEl, paneLayouts);
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;
}
}
}
} }
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; return items;
} }
+37 -5
View File
@@ -23,7 +23,9 @@ import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout'; import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
import { import {
decodeConditionForEditor, decodeConditionForEditor,
alignBuySellStartCandleTypesForSave,
encodeConditionForSave, encodeConditionForSave,
syncBuySellPrimaryStartCandleTypes,
mergeStartMetaForLoad, mergeStartMetaForLoad,
addExtraStartSection, addExtraStartSection,
hasMultipleStartSections, hasMultipleStartSections,
@@ -33,6 +35,7 @@ import {
type EditorConditionState, type EditorConditionState,
} from '../utils/strategyConditionSerde'; } from '../utils/strategyConditionSerde';
import { import {
START_NODE_ID,
defaultStartMeta, defaultStartMeta,
type StartCombineOp, type StartCombineOp,
} from '../utils/strategyStartNodes'; } from '../utils/strategyStartNodes';
@@ -393,9 +396,36 @@ export default function StrategyEditorPage({ theme }: Props) {
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); }, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { 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; const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes)); handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
}, [signalTab, buyEditorState, sellEditorState, handleEditorStateChange]); }, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
layoutStrategyKey,
schedulePersistFlowLayout,
]);
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
@@ -559,8 +589,9 @@ export default function StrategyEditorPage({ theme }: Props) {
} }
setSaveNameError(false); setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.(); if (editorMode === 'graph') layoutFlushRef.current?.();
const encodedBuy = encodeConditionForSave(buyEditorState); const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedSell = encodeConditionForSave(sellEditorState); const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; } if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true); setIsSaving(true);
try { try {
@@ -642,8 +673,9 @@ export default function StrategyEditorPage({ theme }: Props) {
}, [resetFlowLayout, bumpLayoutSeed, signalTab]); }, [resetFlowLayout, bumpLayoutSeed, signalTab]);
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
const encodedBuy = encodeConditionForSave(buyEditorState); const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedSell = encodeConditionForSave(sellEditorState); const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { if (!encodedBuy && !encodedSell) {
showSnack('내보낼 조건이 없습니다', false); showSnack('내보낼 조건이 없습니다', false);
return; return;
+1 -1
View File
@@ -1430,7 +1430,7 @@ function sortedParamKey(inds: IndicatorConfig[]): string {
*/ */
function styleKey(inds: IndicatorConfig[]): string { function styleKey(inds: IndicatorConfig[]): string {
return inds.map(i => 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(';'); ).join(';');
} }
+28 -5
View File
@@ -30,7 +30,8 @@ import {
type IchimokuCloudColors, type IchimokuCloudColors,
resolveIchimokuCloudColors, resolveIchimokuCloudColors,
} from '../utils/ichimokuConfig'; } from '../utils/ichimokuConfig';
import type { IndicatorConfig } from '../types'; import type { HlinesBackground, IndicatorConfig } from '../types';
import { resolveBbBandBackground } from '../utils/bollingerConfig';
import { import {
loadIndicatorSettings, loadIndicatorSettings,
saveIndicatorSettings, saveIndicatorSettings,
@@ -46,6 +47,16 @@ export interface IndicatorVisual {
plots?: PlotDef[]; plots?: PlotDef[];
hlines?: HLineDef[]; hlines?: HLineDef[];
cloudColors?: IchimokuCloudColors; cloudColors?: IchimokuCloudColors;
/** 볼린저밴드 어퍼~로우어 배경 (업비트 백그라운드 그리기) */
bandBackground?: HlinesBackground;
}
/** getVisualConfig 반환 타입 */
export interface IndicatorVisualConfig {
plots: PlotDef[];
hlines: HLineDef[];
cloudColors?: IchimokuCloudColors;
bandBackground?: HlinesBackground;
} }
type VisualCache = Record<string, IndicatorVisual>; type VisualCache = Record<string, IndicatorVisual>;
@@ -204,12 +215,15 @@ export function useIndicatorSettings(sessionKey = 0) {
hlines: cfg.hlines, hlines: cfg.hlines,
}; };
if (cfg.cloudColors) visual.cloudColors = cfg.cloudColors; if (cfg.cloudColors) visual.cloudColors = cfg.cloudColors;
if (cfg.type === 'BollingerBands' && cfg.bandBackground) {
visual.bandBackground = cfg.bandBackground;
}
return { type: cfg.type, visual }; return { type: cfg.type, visual };
}); });
await Promise.all( await Promise.all(
visualEntries.map(({ type, visual }) => 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, type: string,
defaultPlots?: PlotDef[], defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[] defaultHlines?: HLineDef[],
): { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors } => { ): IndicatorVisualConfig => {
const saved = _visualCache?.[type]; const saved = _visualCache?.[type];
const def = getIndicatorDef(type); const def = getIndicatorDef(type);
const registryPlots = defaultPlots ?? def?.plots ?? []; const registryPlots = defaultPlots ?? def?.plots ?? [];
@@ -265,11 +279,16 @@ export function useIndicatorSettings(sessionKey = 0) {
? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS) ? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS)
: undefined; : undefined;
const bandBackground = type === 'BollingerBands'
? resolveBbBandBackground(saved?.bandBackground)
: undefined;
// 깊은 복사: 여러 슬롯이 같은 참조를 공유하지 않도록 // 깊은 복사: 여러 슬롯이 같은 참조를 공유하지 않도록
return { return {
plots: plots.map(p => ({ ...p })), plots: plots.map(p => ({ ...p })),
hlines: hlines.map(h => ({ ...h })), hlines: hlines.map(h => ({ ...h })),
cloudColors: cloudColors ? { ...cloudColors } : undefined, cloudColors: cloudColors ? { ...cloudColors } : undefined,
bandBackground: bandBackground ? { ...bandBackground } : undefined,
}; };
}, },
[] []
@@ -285,15 +304,19 @@ export function useIndicatorSettings(sessionKey = 0) {
plots?: PlotDef[], plots?: PlotDef[],
hlines?: HLineDef[], hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors, cloudColors?: IchimokuCloudColors,
bandBackground?: HlinesBackground,
) => { ) => {
const visual: IndicatorVisual = { plots, hlines }; const visual: IndicatorVisual = { plots, hlines };
if (cloudColors) visual.cloudColors = cloudColors; if (cloudColors) visual.cloudColors = cloudColors;
if (type === 'BollingerBands') {
visual.bandBackground = bandBackground ?? resolveBbBandBackground(_visualCache?.[type]?.bandBackground);
}
if (_visualCache) { if (_visualCache) {
_visualCache = { ..._visualCache, [type]: visual }; _visualCache = { ..._visualCache, [type]: visual };
} else { } else {
_visualCache = { [type]: visual }; _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) console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err)
); );
notifyIndicatorSettingsChanged(); notifyIndicatorSettingsChanged();
+10 -1
View File
@@ -147,11 +147,20 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean {
} }
function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void { function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void {
if (!shouldShowBbBandFill(config)) {
detachBbBandFill(entry);
return;
}
const basis = seriesByPlotId(entry, 'plot0'); const basis = seriesByPlotId(entry, 'plot0');
const upper = seriesByPlotId(entry, 'plot1'); const upper = seriesByPlotId(entry, 'plot1');
const lower = seriesByPlotId(entry, 'plot2'); const lower = seriesByPlotId(entry, 'plot2');
if (!basis || !upper || !lower || !shouldShowBbBandFill(config)) return; if (!basis || !upper || !lower) return;
const bg = resolveBbBandBackground(config.bandBackground); const bg = resolveBbBandBackground(config.bandBackground);
if (entry.bbFillPrimitive) {
entry.bbFillPrimitive.updateBackground(bg);
entry.bbFillPrimitive.requestRefresh();
return;
}
entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg); entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg);
basis.attachPrimitive(entry.bbFillPrimitive); basis.attachPrimitive(entry.bbFillPrimitive);
} }
+3 -3
View File
@@ -329,10 +329,10 @@ export async function saveIndicatorSettings(
* DB . * DB .
*/ */
export async function loadIndicatorVisualSettings(): Promise< export async function loadIndicatorVisualSettings(): Promise<
Record<string, { plots?: unknown[]; hlines?: unknown[] }> Record<string, { plots?: unknown[]; hlines?: unknown[]; cloudColors?: unknown; bandBackground?: unknown }>
> { > {
return ( return (
(await request<Record<string, { plots?: unknown[]; hlines?: unknown[] }>>( (await request<Record<string, { plots?: unknown[]; hlines?: unknown[]; cloudColors?: unknown; bandBackground?: unknown }>>(
'/indicator-settings/visual' '/indicator-settings/visual'
)) ?? {} )) ?? {}
); );
@@ -347,7 +347,7 @@ export async function loadIndicatorVisualSettings(): Promise<
*/ */
export async function saveIndicatorVisualSettings( export async function saveIndicatorVisualSettings(
indicatorType: string, indicatorType: string,
visual: { plots?: unknown[]; hlines?: unknown[] } visual: { plots?: unknown[]; hlines?: unknown[]; cloudColors?: unknown; bandBackground?: unknown },
): Promise<void> { ): Promise<void> {
await request(`/indicator-settings/${encodeURIComponent(indicatorType)}/visual`, { await request(`/indicator-settings/${encodeURIComponent(indicatorType)}/visual`, {
method: 'PATCH', method: 'PATCH',
+3 -2
View File
@@ -151,7 +151,7 @@ type GetVisual = (
type: string, type: string,
defaultPlots?: PlotDef[], defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[], defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors; bandBackground?: import('../types').HlinesBackground };
/** 탭 전체 추가 — 기존 차트 지표를 제거하고 types 목록만 새로 구성 */ /** 탭 전체 추가 — 기존 차트 지표를 제거하고 types 목록만 새로 구성 */
export function replaceIndicatorConfigsFromTypes( export function replaceIndicatorConfigsFromTypes(
@@ -181,7 +181,7 @@ export function buildIndicatorConfigsFromTypes(
seen.add(type); seen.add(type);
const params = getParams(type, def.defaultParams); 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 = { let cfg: IndicatorConfig = {
id: newId(), id: newId(),
type: def.type, type: def.type,
@@ -189,6 +189,7 @@ export function buildIndicatorConfigsFromTypes(
plots, plots,
hlines, hlines,
...(cloudColors ? { cloudColors } : {}), ...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
}; };
if (type === 'SMA') { if (type === 'SMA') {
cfg = normalizeSmaConfig({ cfg = normalizeSmaConfig({
@@ -54,7 +54,7 @@ export function initializeIndicatorConfigForEditor(
type: string, type: string,
defaultPlots: PlotDef[], defaultPlots: PlotDef[],
defaultHlines?: HLineDef[], defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }, ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors; bandBackground?: HlinesBackground },
): IndicatorConfig { ): IndicatorConfig {
const def = getIndicatorDef(raw.type); const def = getIndicatorDef(raw.type);
if (!def) return enrichIndicatorConfig(raw); if (!def) return enrichIndicatorConfig(raw);
@@ -70,6 +70,7 @@ export function initializeIndicatorConfigForEditor(
plots: raw.plots?.length ? raw.plots : visual?.plots, plots: raw.plots?.length ? raw.plots : visual?.plots,
hlines: raw.hlines?.length ? raw.hlines : visual?.hlines, hlines: raw.hlines?.length ? raw.hlines : visual?.hlines,
cloudColors: raw.cloudColors ?? visual?.cloudColors, cloudColors: raw.cloudColors ?? visual?.cloudColors,
bandBackground: raw.bandBackground ?? visual?.bandBackground,
}); });
} }
+16 -1
View File
@@ -68,12 +68,27 @@ export function resolveHistogramDownColor(plot: PlotDef): string {
return plot.histogramDownColor ?? DEFAULT_HIST_DOWN; 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( export function histogramBarColor(
value: number, value: number,
prev: number | null | undefined, prev: number | null | undefined,
plot: PlotDef, plot: PlotDef,
): string { ): string {
if (isMacdDualHistogramPlot(plot)) {
const raw = value < 0 ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
return withHistogramAlpha(raw);
}
const isDown = prev != null && !isNaN(prev) && value < prev; const isDown = prev != null && !isNaN(prev) && value < prev;
const isNeg = value < 0; const isNeg = value < 0;
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot); const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
@@ -399,6 +399,31 @@ export function collectTimeframesFromEditorState(state: EditorConditionState): s
return [...set]; 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용 분기 목록 */ /** 저장된 DSL에서 Logic Expression용 분기 목록 */
export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
return collectEditorBranches(decodeConditionForEditor(dsl)); return collectEditorBranches(decodeConditionForEditor(dsl));