diff --git a/frontend/src/App.css b/frontend/src/App.css index f0257f6..1a4bd7f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1194,6 +1194,31 @@ html.theme-blue { scrollbar-width: thin; scrollbar-color: var(--bg4) transparent; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; +} +.ind-panel-add-all { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + padding: 0; + background: var(--bg3); + color: var(--text2); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.ind-panel-add-all:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 12%, var(--bg3)); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.ind-panel-add-all:disabled { + opacity: 0.35; + cursor: not-allowed; } .ind-panel-cats-actions { display: flex; @@ -11492,6 +11517,42 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } } .gc-popup-close:hover { color: var(--text); background: var(--bg4); } +/* 지표 추가 팝업 — 타이틀바 탭 관리 */ +.ind-panel-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.ind-panel-header-btn { + padding: 3px 8px; + background: var(--bg2); + color: var(--text2); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.ind-panel-header-btn:hover:not(:disabled) { + background: var(--bg4); + color: var(--text); +} +.ind-panel-header-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.ind-panel-header-btn.danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.35); +} +.ind-panel-header-actions .gc-popup-close { + margin-right: 2px; +} + .mcs-dialog, .ism-dialog, .indicator-modal, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 386a64e..48f904f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ import { mergeIndicators, reorderIndicatorGroup, splitIndicatorPane, + buildIndicatorConfigsFromTypes, } from './utils/indicatorPaneMerge'; import { useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; @@ -1365,6 +1366,22 @@ function App() { }); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); + /** 지표 추가 팝업 — 탭 전체 추가 (한 번에 상태 반영) */ + const handleAddIndicators = useCallback((types: string[]) => { + if (!types.length) return; + if (layoutDef.count > 1) { + const slotRef = slotRefs.current[activeSlot]; + if (slotRef) { + slotRef.addIndicators(types); + return; + } + } + setIndicators(prev => { + const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId); + return added.length > 0 ? [...prev, ...added] : prev; + }); + }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); + /** * 지표 제거 (type 기준): * - 싱글: 메인 차트에서 제거 @@ -1795,6 +1812,7 @@ function App() { onFitContent={() => managerRef.current?.fitContent()} onScrollToNow={() => managerRef.current?.scrollToRealTime()} onAddIndicator={handleAddIndicator} + onAddIndicators={handleAddIndicators} onRemoveByType={handleRemoveByType} onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)} onOpenIndicatorSettings={handleOpenIndicatorSettings} diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 966bfcf..a3f6101 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -32,6 +32,7 @@ import { mergeIndicators, reorderIndicatorGroup, splitIndicatorPane, + buildIndicatorConfigsFromTypes, } from '../utils/indicatorPaneMerge'; import { saveSlot } from '../utils/backendApi'; import type { @@ -85,6 +86,8 @@ function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; } export interface ChartSlotHandle { /** 지표 추가 */ addIndicator: (type: string, fromConfig?: IndicatorConfig) => void; + /** 지표 일괄 추가 (탭 전체 추가) */ + addIndicators: (types: string[]) => void; /** 지표 제거 (type 기준) */ removeIndicatorByType: (type: string) => void; /** 보조지표 pane 복사 */ @@ -268,6 +271,13 @@ const ChartSlot = forwardRef(function ChartSlot return [...prev, cfg]; }); }, + addIndicators: (types: string[]) => { + if (!types.length) return; + setIndicators(prev => { + const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId); + return added.length > 0 ? [...prev, ...added] : prev; + }); + }, removeIndicatorByType: (type: string) => { setIndicators(prev => prev.filter(i => i.type !== type)); }, diff --git a/frontend/src/components/IndicatorCustomTabEditor.tsx b/frontend/src/components/IndicatorCustomTabEditor.tsx index 7487b5d..bfe9388 100644 --- a/frontend/src/components/IndicatorCustomTabEditor.tsx +++ b/frontend/src/components/IndicatorCustomTabEditor.tsx @@ -27,9 +27,14 @@ const IndicatorCustomTabEditor: React.FC = ({ const [search, setSearch] = useState(''); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopImmediatePropagation(); + onClose(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); }, [onClose]); const filtered = useMemo(() => { diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 407025e..56eb1d6 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -8,7 +8,7 @@ import type { Theme } from '../types/index'; import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import type { LogicNode } from '../utils/strategyTypes'; import { - buildStrategyEditorDef, + buildStrategyEditorDefFromSettings, loadStratsLocal, saveStratsLocal, mergeAtRoot, @@ -18,6 +18,8 @@ import { genId, type StrategyDto, } from '../utils/strategyEditorShared'; +import { syncLogicNodeWithGlobalDef } from '../utils/strategyDefSync'; +import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout'; import { decodeConditionForEditor, @@ -128,7 +130,12 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) { } export default function StrategyEditorPage({ theme }: Props) { - const DEF = useMemo(() => buildStrategyEditorDef(), []); + const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings(); + const DEF = useMemo( + () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), + [getParams, getVisualConfig, settingsRevision], + ); + const settingsSyncRef = useRef(0); const [strategies, setStrategies] = useState(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(null); @@ -247,6 +254,36 @@ export default function StrategyEditorPage({ theme }: Props) { const orphanTotal = buyOrphans.length + sellOrphans.length; + /** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드 기준값 동기화 */ + useEffect(() => { + if (settingsRevision <= settingsSyncRef.current) return; + settingsSyncRef.current = settingsRevision; + + const syncExtraRoots = ( + roots: Record, + signalType: 'buy' | 'sell', + ) => { + const next: Record = {}; + for (const [key, node] of Object.entries(roots)) { + next[key] = node ? syncLogicNodeWithGlobalDef(node, DEF, signalType) : null; + } + return next; + }; + + setBuyCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'buy') : prev)); + setSellCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'sell') : prev)); + setBuyOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'buy'))); + setSellOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'sell'))); + setBuyLayout(prev => ({ + ...prev, + extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'buy'), + })); + setSellLayout(prev => ({ + ...prev, + extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'), + })); + }, [settingsRevision, DEF]); + const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft'; const persistFlowLayout = useCallback((strategyKey: string) => { diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index d37e506..2cb6751 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -72,6 +72,7 @@ export interface ToolbarProps { onFitContent: () => void; onScrollToNow?: () => void; onAddIndicator: (type: string) => void; + onAddIndicators?: (types: string[]) => void; onRemoveByType: (type: string) => void; onAutoFib: () => void; onClearFib: () => void; @@ -302,13 +303,14 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [ interface IndDropdownProps { activeIndicators: IndicatorConfig[]; onAdd: (type: string) => void; + onAddMany?: (types: string[]) => void; onRemove: (type: string) => void; onOpenSettings?: (type: string) => void; onClose: () => void; } const IndDropdown: React.FC = ({ - activeIndicators, onAdd, onRemove, onOpenSettings, onClose, + activeIndicators, onAdd, onAddMany, onRemove, onOpenSettings, onClose, }) => { const [search, setSearch] = React.useState(''); const [category, setCategory] = React.useState('All'); @@ -316,6 +318,7 @@ const IndDropdown: React.FC = ({ const [editorOpen, setEditorOpen] = React.useState(false); const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create'); const searchRef = useRef(null); + const searchFocusedRef = useRef(false); const refreshCustomTabs = useCallback(() => { setCustomTabs(loadCustomTabs()); @@ -358,13 +361,63 @@ const IndDropdown: React.FC = ({ setSearch(''); }; + const addAllContext = React.useMemo(() => { + if (category === 'All') return null; + if (category === 'Main') { + return { label: '주요지표', types: [...MAIN_INDICATOR_TYPES] }; + } + if (isCustomTabKey(category) && selectedCustomTab) { + const types = selectedCustomTab.indicatorTypes.filter( + t => INDICATOR_REGISTRY.some(d => d.type === t), + ); + return { label: selectedCustomTab.name, types }; + } + return null; + }, [category, selectedCustomTab]); + + const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; + + const handleAddAllInTab = () => { + if (!addAllContext || addAllContext.types.length === 0) return; + const validTypes = addAllContext.types.filter( + type => INDICATOR_REGISTRY.some(d => d.type === type), + ); + const toAdd = validTypes.filter(type => !isActive(type)); + if (toAdd.length === 0) { + window.alert('추가할 지표가 없습니다. (이미 모두 차트에 있습니다)'); + return; + } + const msg = `${addAllContext.label} 탭에 있는 지표 ${toAdd.length}개를 차트에 추가하시겠습니까?`; + if (!window.confirm(msg)) return; + if (onAddMany) { + onAddMany(toAdd); + } else { + toAdd.forEach(type => onAdd(type)); + } + }; + + const stopHeaderBtn = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + useEffect(() => { + if (editorOpen || searchFocusedRef.current) return; searchRef.current?.focus(); - // ESC 키로 닫기 - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + searchFocusedRef.current = true; + }, [editorOpen]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (editorOpen) { + setEditorOpen(false); + return; + } + onClose(); + }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onClose]); + }, [onClose, editorOpen]); const isActive = (type: string) => activeIndicators.some(a => a.type === type); @@ -429,7 +482,35 @@ const IndDropdown: React.FC = ({ style={{ cursor: headerCursor, ...headerTouchStyle }} > 지표 추가 - +
+ + + + +
{/* 검색창 */} @@ -451,7 +532,7 @@ const IndDropdown: React.FC = ({ {/* 카테고리 탭 */}
-
+
{BUILTIN_TABS.map(c => { const cnt = c.key === 'All' ? INDICATOR_REGISTRY.length @@ -460,6 +541,8 @@ const IndDropdown: React.FC = ({ ))}
-
- - - -
+
{/* 지표 목록 */} @@ -618,7 +692,7 @@ const Toolbar: React.FC = ({ symbol, timeframe, chartType, theme, mode, logScale, showGrid, activeIndicators, onSymbol, onTimeframe, onChartType, onTheme, onMode, - onFitContent, onScrollToNow, onAddIndicator, onRemoveByType, + onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onRemoveByType, onAutoFib, onClearFib, onScreenshot, onToggleStats, onToggleWatch, onToggleAlert, tradeNotifyUnread = 0, onOpenTradeNotifications, @@ -795,6 +869,7 @@ const Toolbar: React.FC = ({ onAddIndicator(type)} + onAddMany={onAddIndicators} onRemove={type => onRemoveByType(type)} onOpenSettings={onOpenIndicatorSettings} onClose={() => setShowIndMenu(false)} diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index c957893..aa70a1a 100644 --- a/frontend/src/hooks/useIndicatorSettings.ts +++ b/frontend/src/hooks/useIndicatorSettings.ts @@ -21,7 +21,7 @@ * ``` */ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { getIndicatorDef, mergePlotDefs, normalizeHLines, PlotDef, HLineDef } from '../utils/indicatorRegistry'; import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig'; import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig'; @@ -61,6 +61,7 @@ function ensureLoaded(): Promise { _loadPromise = loadIndicatorSettings().then(data => { _cache = (data as AllSettings) ?? {}; _loadPromise = null; + notifyIndicatorSettingsChanged(); return _cache; }); return _loadPromise; @@ -72,11 +73,25 @@ function ensureVisualLoaded(): Promise { _visualLoadPromise = loadIndicatorVisualSettings().then(data => { _visualCache = (data as VisualCache) ?? {}; _visualLoadPromise = null; + notifyIndicatorSettingsChanged(); return _visualCache; }); return _visualLoadPromise; } +type SettingsListener = () => void; +const _settingsListeners = new Set(); + +function notifyIndicatorSettingsChanged() { + _settingsListeners.forEach(fn => fn()); +} + +/** 보조지표 설정 변경 구독 (전략편집기 DEF 동기화 등) */ +export function subscribeIndicatorSettings(listener: SettingsListener): () => void { + _settingsListeners.add(listener); + return () => { _settingsListeners.delete(listener); }; +} + /** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */ export function invalidateIndicatorSettingsCache() { _cache = null; @@ -88,15 +103,21 @@ export function invalidateIndicatorSettingsCache() { // ───────────────────────────────────────────────────────────────────────────── export function useIndicatorSettings(sessionKey = 0) { + const [settingsRevision, setSettingsRevision] = useState(0); + // 로그인/로그아웃 시 지표 설정 재로드 useEffect(() => { invalidateIndicatorSettingsCache(); - ensureLoaded().catch(err => - console.error('[useIndicatorSettings] params load failed', err) - ); - ensureVisualLoaded().catch(err => - console.error('[useIndicatorSettings] visual load failed', err) - ); + const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1)); + Promise.all([ + ensureLoaded().catch(err => + console.error('[useIndicatorSettings] params load failed', err), + ), + ensureVisualLoaded().catch(err => + console.error('[useIndicatorSettings] visual load failed', err), + ), + ]).then(() => setSettingsRevision(r => r + 1)); + return unsub; }, [sessionKey]); // ── 파라미터 ──────────────────────────────────────────────────────────── @@ -135,6 +156,7 @@ export function useIndicatorSettings(sessionKey = 0) { saveIndicatorSettings(type, params as ParamMap).catch(err => console.error(`[useIndicatorSettings] saveParams failed for ${type}`, err) ); + notifyIndicatorSettingsChanged(); }, [] ); @@ -146,6 +168,7 @@ export function useIndicatorSettings(sessionKey = 0) { saveAllIndicatorSettings(allParams).catch(err => console.error('[useIndicatorSettings] saveAll failed', err) ); + notifyIndicatorSettingsChanged(); }, [] ); @@ -222,9 +245,10 @@ export function useIndicatorSettings(sessionKey = 0) { saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err => console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err) ); + notifyIndicatorSettingsChanged(); }, [] ); - return { getParams, saveParams, saveAll, getVisualConfig, saveVisual }; + return { getParams, saveParams, saveAll, getVisualConfig, saveVisual, settingsRevision }; } diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 2ba1251..a4b4d08 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -31,7 +31,7 @@ export interface IndicatorPeriodDef { investPsy: number; } -const VALUE_FIELD_PREFIX: Record = { +export const VALUE_FIELD_PREFIX: Record = { RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE', @@ -69,33 +69,70 @@ function parseFieldPeriod(field?: string): number | null { return Number.isFinite(n) && n > 0 ? n : null; } +/** 조건 노드의 RSI 등 값(좌측) 기간 — 오버라이드 시 DSL, 아니면 보조지표 설정(DEF) */ +export function isValuePeriodOverridden(cond: ConditionDSL): boolean { + if (cond.valuePeriodOverride === true) return true; + if (cond.valuePeriodOverride === false) return false; + if (cond.period != null && cond.period > 0) return true; + if (cond.leftPeriod != null && cond.leftPeriod > 0) return true; + if (isValuePeriodFieldStored(cond.indicatorType, cond.leftField)) return true; + return false; +} + +export function isRightPeriodOverridden(cond: ConditionDSL): boolean { + if (cond.rightPeriodOverride === true) return true; + if (cond.rightPeriodOverride === false) return false; + if (cond.rightPeriod != null && cond.rightPeriod > 0) return true; + return false; +} + +export function isThresholdOverridden(cond: ConditionDSL): boolean { + if (cond.thresholdOverride === true) return true; + if (cond.thresholdOverride === false) return false; + return false; +} + /** 조건 노드의 RSI 등 값(좌측) 기간 */ export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { if (isPriceExtremeIndicator(cond.indicatorType)) { - if (cond.period && cond.period > 0) return cond.period; - const fromRight = parsePriorExtremePeriod(cond.rightField); - if (fromRight) return fromRight; + if (isValuePeriodOverridden(cond)) { + if (cond.period && cond.period > 0) return cond.period; + const fromRight = parsePriorExtremePeriod(cond.rightField); + if (fromRight) return fromRight; + } return getDefaultIndicatorPeriod(cond.indicatorType, def); } if (cond.composite) { - if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod; - const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); + if (isValuePeriodOverridden(cond)) { + if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod; + const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); + if (fromField) return fromField; + } + const { short } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef); + return short; + } + if (isValuePeriodOverridden(cond)) { + if (cond.period && cond.period > 0) return cond.period; + const fromField = parseFieldPeriod(cond.leftField); if (fromField) return fromField; } - if (cond.period && cond.period > 0) return cond.period; - const fromField = parseFieldPeriod(cond.leftField); - if (fromField) return fromField; return getDefaultIndicatorPeriod(cond.indicatorType, def); } export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { if (cond.composite) { - if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod; - const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField); + if (isRightPeriodOverridden(cond)) { + if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod; + const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField); + if (fromField) return fromField; + } + const { long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef); + return long; + } + if (isValuePeriodOverridden(cond)) { + const fromField = parseFieldPeriod(cond.rightField); if (fromField) return fromField; } - const fromField = parseFieldPeriod(cond.rightField); - if (fromField) return fromField; return getDefaultIndicatorPeriod(cond.indicatorType, def); } @@ -142,6 +179,9 @@ export function hasEditableThreshold(cond: ConditionDSL): boolean { } export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null { + if (side === 'right' && !isThresholdOverridden(cond)) { + return parseThresholdField(cond.rightField); + } const field = side === 'left' ? cond.leftField : cond.rightField; return parseThresholdField(field); } @@ -153,13 +193,13 @@ export function setConditionValuePeriod( ): ConditionDSL { const p = Math.max(1, Math.min(500, Math.round(period))); if (isPriceExtremeIndicator(cond.indicatorType)) { - return syncPriceExtremeSimpleFields({ ...cond, period: p }); + return syncPriceExtremeSimpleFields({ ...cond, period: p, valuePeriodOverride: true }); } if (cond.composite) { - return syncCompositeFields({ ...cond, composite: true, leftPeriod: p }); + return syncCompositeFields({ ...cond, composite: true, leftPeriod: p, valuePeriodOverride: true }); } const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; - const next: ConditionDSL = { ...cond, period: p }; + const next: ConditionDSL = { ...cond, period: p, valuePeriodOverride: true }; if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) { next.leftField = `${prefix}_${p}`; } @@ -169,7 +209,7 @@ export function setConditionValuePeriod( export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL { const p = Math.max(1, Math.min(500, Math.round(period))); if (!cond.composite) return cond; - return syncCompositeFields({ ...cond, composite: true, rightPeriod: p }); + return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true }); } export function setConditionThreshold( @@ -184,9 +224,41 @@ export function setConditionThreshold( ...cond, [fieldKey]: thresholdField(value), targetValue: value, + thresholdOverride: true, }; } +/** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */ +export function initConditionPeriodsInherit( + indicatorType: string, + condition: ConditionDSL, + def: IndicatorPeriodDef, +): ConditionDSL { + const base = { + ...condition, + valuePeriodOverride: false as const, + thresholdOverride: false as const, + rightPeriodOverride: false as const, + }; + if (isPriceExtremeIndicator(indicatorType)) { + const period = getDefaultIndicatorPeriod(indicatorType, def); + return syncPriceExtremeSimpleFields({ ...base, period }); + } + if (condition.composite) return base; + const prefix = VALUE_FIELD_PREFIX[indicatorType]; + if (!prefix) { + const { period: _p, ...rest } = base; + return rest; + } + const lf = condition.leftField ?? ''; + if (lf === prefix || lf.startsWith(`${prefix}_`)) { + const { period: _p, ...rest } = base; + return { ...rest, leftField: prefix }; + } + const { period: _p, ...rest } = base; + return rest; +} + export function initConditionPeriods( indicatorType: string, condition: ConditionDSL, diff --git a/frontend/src/utils/indicatorPaneMerge.ts b/frontend/src/utils/indicatorPaneMerge.ts index 10352fb..1360e4f 100644 --- a/frontend/src/utils/indicatorPaneMerge.ts +++ b/frontend/src/utils/indicatorPaneMerge.ts @@ -1,5 +1,9 @@ import type { IndicatorConfig } from '../types'; import { enrichIndicatorConfig, getIndicatorDef } from './indicatorRegistry'; +import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './smaConfig'; +import { normalizeIchimokuConfig } from './ichimokuConfig'; +import type { PlotDef, HLineDef } from './indicatorRegistry'; +import type { IchimokuCloudColors } from './ichimokuConfig'; /** 보조지표 인스턴스 복제 (새 id, 별도 pane) */ export function cloneIndicatorConfig(src: IndicatorConfig, newId: string): IndicatorConfig { @@ -137,3 +141,54 @@ export function splitIndicatorPane( export function layoutKey(inds: IndicatorConfig[]): string { return inds.map(i => `${i.id}>${i.mergedWith ?? ''}`).join(';'); } + +type GetParams = ( + type: string, + defaults?: Record, +) => Record; + +type GetVisual = ( + type: string, + defaultPlots?: PlotDef[], + defaultHlines?: HLineDef[], +) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; + +/** 지표 타입 목록 → 차트에 추가할 IndicatorConfig 배열 (이미 활성인 type 제외) */ +export function buildIndicatorConfigsFromTypes( + types: string[], + existing: IndicatorConfig[], + getParams: GetParams, + getVisualConfig: GetVisual, + newId: () => string, +): IndicatorConfig[] { + const seen = new Set(existing.map(i => i.type)); + const out: IndicatorConfig[] = []; + + for (const type of types) { + if (seen.has(type)) continue; + const def = getIndicatorDef(type); + if (!def) continue; + seen.add(type); + + const params = getParams(type, def.defaultParams); + const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + let cfg: IndicatorConfig = { + id: newId(), + type: def.type, + params, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + }; + if (type === 'SMA') { + cfg = normalizeSmaConfig({ + ...cfg, + plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), + }); + } else if (type === 'IchimokuCloud') { + cfg = normalizeIchimokuConfig(cfg); + } + out.push(cfg); + } + return out; +} diff --git a/frontend/src/utils/strategyDefSync.ts b/frontend/src/utils/strategyDefSync.ts new file mode 100644 index 0000000..38fd618 --- /dev/null +++ b/frontend/src/utils/strategyDefSync.ts @@ -0,0 +1,117 @@ +/** 보조지표 전역 설정(DEF) 변경 시 전략 조건 트리 동기화 — 오버라이드되지 않은 노드만 */ +import type { LogicNode } from './strategyTypes'; +import type { ConditionDSL } from './strategyTypes'; +import { + getCompositeDefaultPeriods, + syncCompositeFields, + type CompositePeriodDef, +} from './compositeIndicators'; +import { + isPriceExtremeIndicator, + syncPriceExtremeSimpleFields, +} from './priceExtremeIndicators'; +import { + getDefaultIndicatorPeriod, + isRightPeriodOverridden, + isThresholdOverridden, + isValuePeriodOverridden, + parseThresholdField, + usesValuePeriodField, + VALUE_FIELD_PREFIX, +} from './conditionPeriods'; +import type { DefType } from './strategyEditorShared'; +import { getDefaultConditionFields } from './strategyEditorShared'; + +function applyGlobalDefToCondition( + cond: ConditionDSL, + def: DefType, + signalType: 'buy' | 'sell', +): ConditionDSL { + let next: ConditionDSL = { ...cond }; + + if (isPriceExtremeIndicator(cond.indicatorType)) { + if (!isValuePeriodOverridden(cond)) { + const period = getDefaultIndicatorPeriod(cond.indicatorType, def); + next = syncPriceExtremeSimpleFields({ ...next, period, valuePeriodOverride: false }); + } + return next; + } + + if (cond.composite) { + const { short, long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef); + if (!isValuePeriodOverridden(cond)) { + next = syncCompositeFields({ + ...next, + composite: true, + leftPeriod: short, + valuePeriodOverride: false, + }); + } + if (!isRightPeriodOverridden(cond)) { + next = syncCompositeFields({ + ...next, + composite: true, + rightPeriod: long, + rightPeriodOverride: false, + }); + } + return next; + } + + if (!isValuePeriodOverridden(cond)) { + const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; + const { period: _omit, ...rest } = next; + next = { ...rest, valuePeriodOverride: false }; + if (prefix && usesValuePeriodField(cond)) { + next.leftField = prefix; + } + } + + if (!isThresholdOverridden(cond) && parseThresholdField(next.rightField) != null) { + const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def); + next.rightField = defaults.r; + const parsed = parseThresholdField(defaults.r); + if (parsed != null) next.targetValue = parsed; + } + + return next; +} + +function syncConditionNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode { + if (node.type !== 'CONDITION' || !node.condition) return node; + return { + ...node, + condition: applyGlobalDefToCondition(node.condition, def, signalType), + }; +} + +function syncTreeNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode { + let next = syncConditionNode(node, def, signalType); + if (next.children?.length) { + next = { + ...next, + children: next.children.map(c => syncTreeNode(c, def, signalType)), + }; + } + return next; +} + +export function syncLogicNodeWithGlobalDef( + node: LogicNode, + def: DefType, + signalType: 'buy' | 'sell', +): LogicNode { + return syncTreeNode(node, def, signalType); +} + +export function syncLogicRootWithGlobalDef( + root: LogicNode | null, + orphans: LogicNode[], + def: DefType, + signalType: 'buy' | 'sell', +): { root: LogicNode | null; orphans: LogicNode[] } { + return { + root: root ? syncTreeNode(root, def, signalType) : null, + orphans: orphans.map(n => syncTreeNode(n, def, signalType)), + }; +} diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index b3eec22..8b1a66a 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -1,7 +1,7 @@ /** Shared strategy editor utilities — extracted from StrategyPage */ import React from 'react'; import type { IndicatorConfig } from '../types/index'; -import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry'; +import { getIndicatorDef, getHLineLabel, type PlotDef, type HLineDef } from '../utils/indicatorRegistry'; import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes'; import { compositeDisplayName, @@ -32,7 +32,7 @@ import { getPeriodPresetOptions, getThresholdBounds, getThresholdPresetOptions, - initConditionPeriods, + initConditionPeriodsInherit, isValuePeriodFieldStored, parseThresholdField, resolveFieldCustomKind, @@ -256,9 +256,45 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType { } /** - * 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값. - * 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다. + * 전략편집기 DEF — DB 보조지표 설정(파라미터·hline)에서 구성. + * 조건 노드 기본값은 valuePeriodOverride/thresholdOverride=false 일 때 여기서 상속. */ +export function buildStrategyEditorDefFromSettings( + getParams: ( + type: string, + defaults?: Record, + ) => Record, + getVisualConfig: ( + type: string, + defaultPlots?: PlotDef[], + defaultHlines?: HLineDef[], + ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: unknown }, +): DefType { + const registryTypes = [ + 'RSI', 'MACD', 'CCI', 'Stochastic', 'ADX', 'TRIX', 'DMI', 'BollingerBands', + 'IchimokuCloud', 'WilliamsPercentRange', 'VolumeOscillator', 'VR', 'Disparity', + 'Psychological', 'InvestPsychological', 'OBV', 'DonchianChannels', 'PriceExtreme', + 'SMA', + ]; + const configs: IndicatorConfig[] = []; + for (const type of registryTypes) { + const def = getIndicatorDef(type); + if (!def) continue; + const params = getParams(type, def.defaultParams as Record); + const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + configs.push({ + id: `se-def-${type}`, + type, + params: { ...params }, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + } as IndicatorConfig); + } + return buildDef(configs); +} + +/** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */ export function buildStrategyEditorDef(): DefType { return buildDef([]); } @@ -529,7 +565,7 @@ export const getFieldOpts = ( } }; -const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => { +const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => { // 저장된 hline 과열선 값 우선 사용 const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def); const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def); @@ -566,6 +602,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): return map[ind] ?? { l:'NONE', r:'NONE' }; }; +export { getDefaultConditionFields }; + // conditionType 변경 시 자동 필드 조정 const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => { const updated = { ...cond, conditionType: newCondType }; @@ -579,7 +617,7 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def } const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF); const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); - const def = getDefaultFields(cond.indicatorType, 'buy', DEF); + const def = getDefaultConditionFields(cond.indicatorType, 'buy', DEF); if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) { if (!isValid(updated.leftField)) updated.leftField = def.l; @@ -761,7 +799,7 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS; const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v); - const defaults = getDefaultFields(normalized.indicatorType, signalType, def); + const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def); const getLeftValue = () => { const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField); @@ -791,7 +829,10 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan ZERO_LINE: 0, }; let upd: ConditionDSL = { ...normalized, rightField: v }; - if (thresholds[v] !== undefined) upd.targetValue = thresholds[v]; + if (thresholds[v] !== undefined) { + upd.targetValue = thresholds[v]; + upd.thresholdOverride = true; + } const priorP = parsePriorExtremePeriod(v); if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) { upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP }); @@ -816,9 +857,9 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan ?? parsePeriodFromCompositeField(normalized.indicatorType, field); if (p == null) return; if (side === 'left') { - onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field })); + onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field, valuePeriodOverride: true })); } else { - onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field })); + onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field, rightPeriodOverride: true })); } }; @@ -954,7 +995,7 @@ export const CondEditor: React.FC = ({ cond, signalType, onChan const base = bases[normalized.indicatorType]; if (base && v === base) { const p = getConditionValuePeriod(normalized, def); - onChange({ ...normalized, leftField: `${base}_${p}`, period: p }); + onChange({ ...normalized, leftField: `${base}_${p}`, period: p, valuePeriodOverride: true }); } else { onChange({ ...normalized, leftField: v }); } @@ -1077,8 +1118,7 @@ export const makeNode = ( } return { id: genId(), type: 'CONDITION', condition }; } - const def = getDefaultFields(value, signalType, DEF); - const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF); + const def = getDefaultConditionFields(value, signalType, DEF); let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN'; if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP'; if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN'; @@ -1088,9 +1128,11 @@ export const makeNode = ( leftField: def.l, rightField: def.r, candleRange: 1, - period, + valuePeriodOverride: false, + thresholdOverride: false, + rightPeriodOverride: false, }; - const condition = initConditionPeriods(value, baseCondition, DEF); + const condition = initConditionPeriodsInherit(value, baseCondition, DEF); return { id: genId(), type: 'CONDITION', condition: isPriceExtremeIndicator(value) diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 7a5fbc2..5202442 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -20,6 +20,12 @@ export interface ConditionDSL { slopePeriod?: number; holdDays?: number; candleRange?: number; + /** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */ + valuePeriodOverride?: boolean; + /** 복합지표 요소2 — false=보조지표 설정 기본값 상속 */ + rightPeriodOverride?: boolean; + /** false=보조지표 hline 기본값 상속, true=전략 조건 전용 임계값 */ + thresholdOverride?: boolean; } export interface LogicNode {