diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 91e2d49..85bc411 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -46,7 +46,7 @@ import { } from '../utils/strategyStartNodes'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; -import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette'; +import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { loadPaletteItems, type PaletteItem, @@ -240,6 +240,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [auxiliaryPalette, setAuxiliaryPalette] = useState(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); + const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [paletteSearch, setPaletteSearch] = useState(''); const [templateSearch, setTemplateSearch] = useState(''); const [userTemplates, setUserTemplates] = useState(() => loadUserStrategyTemplates()); @@ -1157,13 +1158,13 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop }, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]); const applySidewaysFilter = useCallback((filterId: string) => { - const newNode = buildSidewaysFilterNode(filterId, DEF); + const newNode = buildSidewaysFilterNode(filterId, DEF, sidewaysPalette); if (!newNode) return; const root = currentRoot; if (!root) setCurrentRoot(newNode); else setCurrentRoot(mergeAtRoot(root, newNode, false)); scheduleStrategyPersist(); - }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]); + }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]); const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); @@ -1952,6 +1953,8 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop ) : ( void; + onSave: (entry: Omit & { id?: string; builtIn?: boolean }) => void; +} + +export default function SidewaysFilterItemModal({ + open, mode, initial, onClose, onSave, +}: Props) { + const [templateId, setTemplateId] = useState(SIDEWAYS_TEMPLATE_OPTIONS[0]?.value ?? 'adx-lt-25'); + const [label, setLabel] = useState(''); + const [desc, setDesc] = useState(''); + const [category, setCategory] = useState(SIDEWAYS_CATEGORY_OPTIONS[0]?.value ?? 'adx'); + const [filterMode, setFilterMode] = useState('include'); + + useEffect(() => { + if (!open) return; + if (mode === 'edit' && initial) { + setTemplateId(initial.templateId); + setLabel(initial.label); + setDesc(initial.desc); + setCategory(initial.category); + setFilterMode(initial.mode); + return; + } + const tpl = SIDEWAYS_TEMPLATE_OPTIONS[0]; + if (tpl) { + setTemplateId(tpl.value); + setLabel(`${tpl.label} (복사)`); + setDesc(tpl.desc); + setCategory(tpl.category); + setFilterMode(tpl.mode); + } + }, [open, mode, initial]); + + const handleTemplateChange = (next: string) => { + setTemplateId(next); + if (mode !== 'add') return; + const tpl = SIDEWAYS_TEMPLATE_OPTIONS.find(o => o.value === next); + if (!tpl) return; + setLabel(`${tpl.label} (복사)`); + setDesc(tpl.desc); + setCategory(tpl.category); + setFilterMode(tpl.mode); + }; + + if (!open) return null; + + const title = mode === 'add' ? '횡보 필터 추가' : '횡보 필터 수정'; + + const handleSubmit = () => { + const trimmedLabel = label.trim(); + if (!trimmedLabel) { + window.alert('표시 이름을 입력해 주세요.'); + return; + } + onSave({ + id: initial?.id, + builtIn: initial?.builtIn, + templateId, + label: trimmedLabel, + desc: desc.trim() || trimmedLabel, + category, + mode: filterMode, + }); + onClose(); + }; + + return ( + +
+ + + + + setLabel(e.target.value)} + placeholder="예: ADX < 25" + /> + + + setDesc(e.target.value)} + placeholder="짧은 설명" + /> + + + + + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx b/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx index cbdc330..d364657 100644 --- a/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx +++ b/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx @@ -2,11 +2,15 @@ import React, { useMemo, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { DefType } from '../../utils/strategyEditorShared'; import { - DEFAULT_SIDEWAYS_FILTER_ITEMS, filterSidewaysItems, - type SidewaysFilterItem, type SidewaysFilterMode, } from '../../utils/sidewaysFilterPalette'; +import { + createSidewaysFilterPaletteId, + saveSidewaysPaletteItems, + type SidewaysFilterPaletteEntry, +} from '../../utils/sidewaysFilterPaletteStorage'; +import SidewaysFilterItemModal from './SidewaysFilterItemModal'; export interface SidewaysFilterDragPayload { type: 'sidewaysFilter'; @@ -16,16 +20,18 @@ export interface SidewaysFilterDragPayload { interface Props { def: DefType; + items: SidewaysFilterPaletteEntry[]; + onItemsChange: (items: SidewaysFilterPaletteEntry[]) => void; searchQuery: string; selectedItemId: string | null; onSelectItem: (id: string | null) => void; - onAddToCanvas: (item: SidewaysFilterItem) => void; + onAddToCanvas: (item: SidewaysFilterPaletteEntry) => void; } -function modeIcon(mode: SidewaysFilterMode): { glyph: string; cls: string; aria: string } { +function modeIcon(mode: SidewaysFilterMode): { glyph: string; aria: string } { return mode === 'include' - ? { glyph: '▣', cls: 'se-range-chip-icon--include', aria: '횡보' } - : { glyph: '⊘', cls: 'se-range-chip-icon--exclude', aria: '제외' }; + ? { glyph: '▣', aria: '횡보' } + : { glyph: '⊘', aria: '제외' }; } interface HoverHintState { @@ -42,7 +48,7 @@ function SidewaysFilterChip({ onAdd, onHintChange, }: { - item: SidewaysFilterItem; + item: SidewaysFilterPaletteEntry; selected: boolean; onSelect: () => void; onAdd: () => void; @@ -89,7 +95,7 @@ function SidewaysFilterChip({ return (
-
- +
+ +
+ {item.label} + {item.desc} +
+
+
+ 모드 + {rightIcon.glyph}
- {item.label}
); } @@ -136,6 +154,8 @@ function SidewaysFilterHintTooltip({ hint }: { hint: HoverHintState }) { export default function SidewaysFilterPaletteTab({ def, + items, + onItemsChange, searchQuery, selectedItemId, onSelectItem, @@ -144,18 +164,118 @@ export default function SidewaysFilterPaletteTab({ void def; const [modeFilter, setModeFilter] = useState<'all' | SidewaysFilterMode>('all'); const [hoverHint, setHoverHint] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<'add' | 'edit'>('add'); + const [editTarget, setEditTarget] = useState(null); const filtered = useMemo( - () => filterSidewaysItems(DEFAULT_SIDEWAYS_FILTER_ITEMS, searchQuery, modeFilter), - [searchQuery, modeFilter], + () => filterSidewaysItems(items, searchQuery, modeFilter), + [items, searchQuery, modeFilter], ); + const selected = items.find(i => i.id === selectedItemId) ?? null; + + const persist = useCallback((next: SidewaysFilterPaletteEntry[]) => { + onItemsChange(next); + saveSidewaysPaletteItems(next); + }, [onItemsChange]); + + const openAdd = () => { + setEditTarget(null); + setModalMode('add'); + setModalOpen(true); + }; + + const openEdit = () => { + if (!selected) { + window.alert('수정할 횡보 필터를 먼저 선택해 주세요.'); + return; + } + setEditTarget(selected); + setModalMode('edit'); + setModalOpen(true); + }; + + const handleDelete = () => { + if (!selected) { + window.alert('삭제할 횡보 필터를 먼저 선택해 주세요.'); + return; + } + if (!window.confirm(`「${selected.label}」을(를) 목록에서 삭제할까요?`)) return; + const next = items.filter(i => i.id !== selected.id); + persist(next); + onSelectItem(null); + }; + + const handleSave = (draft: Omit & { id?: string; builtIn?: boolean }) => { + if (modalMode === 'add') { + const entry: SidewaysFilterPaletteEntry = { + id: createSidewaysFilterPaletteId(), + templateId: draft.templateId, + label: draft.label, + desc: draft.desc, + category: draft.category, + mode: draft.mode, + }; + persist([...items, entry]); + onSelectItem(entry.id); + return; + } + const id = draft.id ?? editTarget?.id; + if (!id) return; + persist(items.map(i => (i.id === id ? { + ...i, + label: draft.label, + desc: draft.desc, + category: draft.category, + mode: draft.mode, + templateId: draft.templateId, + } : i))); + }; + return (
{hoverHint && }
횡보 필터 - {filtered.length}개 +
+ + + +
@@ -204,6 +324,14 @@ export default function SidewaysFilterPaletteTab({ {filtered.length === 0 && (

검색 결과가 없습니다.

)} + + setModalOpen(false)} + onSave={handleSave} + />
); } diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 8065ebd..2c283dd 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -28,7 +28,7 @@ import { updateNode, type DefType, } from '../../utils/strategyEditorShared'; -import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette'; +import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { START_NODE_ID, diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index d78237d..a9084a2 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -1751,15 +1751,30 @@ color: var(--se-palette-section-range, #f59e0b); } -.se-palette-card--range { - border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 35%, var(--se-border)); +.se-palette-mode-input { + width: auto; + min-width: 34px; + flex: 1; + max-width: 52px; } -.se-palette-card--range:hover { - border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 55%, var(--se-border)); +.se-palette-mode-mark { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + font-size: 0.62rem; + line-height: 1; + flex-shrink: 0; } -.se-palette-card-icon--range { - background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent); - color: var(--se-palette-section-range, #f59e0b); +.se-palette-mode-mark--include { + background: color-mix(in srgb, #38bdf8 18%, transparent); + color: #38bdf8; +} +.se-palette-mode-mark--exclude { + background: color-mix(in srgb, var(--se-palette-range-exclude-accent) 18%, transparent); + color: var(--se-palette-range-exclude-accent); } .se-range-count { @@ -1778,12 +1793,14 @@ flex: 1; padding: 6px 4px; border-radius: 6px; - border: 1px solid var(--se-border); - background: var(--se-btn-bg); + border: 1px solid color-mix(in srgb, var(--se-palette-range-accent) 28%, var(--se-border)); + background: color-mix(in srgb, var(--se-palette-range-accent) 6%, var(--se-btn-bg)); color: var(--se-text-muted); font-size: 0.68rem; font-weight: 600; cursor: pointer; + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent); + transition: border-color 0.12s, background 0.12s, box-shadow 0.12s; } .se-range-mode-tab--on { border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 50%, transparent); @@ -1858,60 +1875,6 @@ color: #f87171; } -.se-range-chip { - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 8px 9px; - min-height: 56px; -} - -.se-range-chip-icons { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; -} - -.se-range-chip-icon { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 5px; - font-size: 0.72rem; - line-height: 1; - flex-shrink: 0; -} - -.se-range-chip-icon--left { - background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent); - color: var(--se-palette-section-range, #f59e0b); -} - -.se-range-chip-icon--right.se-range-chip-icon--include { - background: color-mix(in srgb, #38bdf8 18%, transparent); - color: #38bdf8; -} - -.se-range-chip-icon--right.se-range-chip-icon--exclude { - background: color-mix(in srgb, #f87171 18%, transparent); - color: #f87171; -} - -.se-range-chip-name { - font-size: 0.68rem; - font-weight: 700; - line-height: 1.25; - color: var(--se-text); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: keep-all; -} - .se-range-hint-tooltip { position: fixed; z-index: 10050; @@ -2158,7 +2121,9 @@ .se-palette-card--ind { border-color: color-mix(in srgb, var(--se-palette-ind-accent) 42%, transparent); background: var(--se-palette-ind-bg); - box-shadow: inset 3px 0 0 var(--se-palette-ind-accent); + box-shadow: + inset 3px 0 0 var(--se-palette-ind-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent); } .se-palette-card--ind .se-palette-card-icon { background: color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent); @@ -2166,14 +2131,19 @@ } .se-palette-card--ind:hover { border-color: color-mix(in srgb, var(--se-palette-ind-accent) 65%, transparent); - box-shadow: inset 3px 0 0 var(--se-palette-ind-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent); + box-shadow: + inset 3px 0 0 var(--se-palette-ind-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent), + 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent); } /* ── 복합지표 ── */ .se-palette-card--composite { border-color: color-mix(in srgb, #c084fc 42%, transparent); - background: color-mix(in srgb, #c084fc 8%, var(--se-palette-card-bg)); - box-shadow: inset 3px 0 0 #c084fc; + background: linear-gradient(145deg, color-mix(in srgb, #c084fc 22%, var(--bg2)), color-mix(in srgb, #c084fc 10%, var(--bg2))); + box-shadow: + inset 3px 0 0 #c084fc, + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent); } .se-palette-card--composite .se-palette-card-icon { background: color-mix(in srgb, #c084fc 22%, transparent); @@ -2181,12 +2151,70 @@ } .se-palette-card--composite:hover { border-color: color-mix(in srgb, #c084fc 65%, transparent); - box-shadow: inset 3px 0 0 #c084fc, 0 4px 14px color-mix(in srgb, #c084fc 22%, transparent); + box-shadow: + inset 3px 0 0 #c084fc, + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent), + 0 4px 14px color-mix(in srgb, #c084fc 22%, transparent); } .se-palette-card--selected.se-palette-card--composite { outline-color: #c084fc; } +/* ── 횡보 필터 (base .se-palette-card 뒤에 배치 — 배경 덮어쓰기 방지) ── */ +.se-palette-card.se-palette-card--range, +.se-palette-card.se-palette-card--range-include { + border-color: color-mix(in srgb, var(--se-palette-range-accent) 42%, transparent); + background: var(--se-palette-range-bg); + box-shadow: + inset 3px 0 0 var(--se-palette-range-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent); +} +.se-palette-card.se-palette-card--range .se-palette-card-icon, +.se-palette-card.se-palette-card--range-include .se-palette-card-icon { + background: color-mix(in srgb, var(--se-palette-range-accent) 22%, transparent); + color: var(--se-palette-range-accent); +} +.se-palette-card.se-palette-card--range:hover, +.se-palette-card.se-palette-card--range-include:hover { + border-color: color-mix(in srgb, var(--se-palette-range-accent) 65%, transparent); + box-shadow: + inset 3px 0 0 var(--se-palette-range-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent), + 0 4px 14px color-mix(in srgb, var(--se-palette-range-accent) 22%, transparent); +} +.se-palette-card--selected.se-palette-card.se-palette-card--range, +.se-palette-card--selected.se-palette-card.se-palette-card--range-include { + outline-color: var(--se-palette-range-accent); +} +.se-palette-card.se-palette-card--range-exclude { + border-color: color-mix(in srgb, var(--se-palette-range-exclude-accent) 42%, transparent); + background: var(--se-palette-range-exclude-bg); + box-shadow: + inset 3px 0 0 var(--se-palette-range-exclude-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent); +} +.se-palette-card.se-palette-card--range-exclude .se-palette-card-icon { + background: color-mix(in srgb, var(--se-palette-range-exclude-accent) 22%, transparent); + color: var(--se-palette-range-exclude-accent); +} +.se-palette-card.se-palette-card--range-exclude:hover { + border-color: color-mix(in srgb, var(--se-palette-range-exclude-accent) 65%, transparent); + box-shadow: + inset 3px 0 0 var(--se-palette-range-exclude-accent), + inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent), + 0 4px 14px color-mix(in srgb, var(--se-palette-range-exclude-accent) 22%, transparent); +} +.se-palette-card--selected.se-palette-card.se-palette-card--range-exclude { + outline-color: var(--se-palette-range-exclude-accent); +} +.se-palette-card.se-palette-card--range .se-palette-card-desc { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.25; +} + .se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; } .se-palette-card-icon { width: 24px; height: 24px; border-radius: 6px; diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index 0c73fd6..2a1ecb6 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -68,10 +68,15 @@ --se-palette-band-accent: #a78bfa; --se-palette-band-bg: color-mix(in srgb, #a78bfa 14%, var(--bg2)); --se-palette-ind-accent: #34d399; - --se-palette-ind-bg: color-mix(in srgb, #34d399 14%, var(--bg2)); + --se-palette-ind-bg: linear-gradient(145deg, color-mix(in srgb, #34d399 22%, var(--bg2)), color-mix(in srgb, #34d399 10%, var(--bg2))); + --se-palette-range-accent: #f59e0b; + --se-palette-range-bg: linear-gradient(145deg, color-mix(in srgb, #f59e0b 22%, var(--bg2)), color-mix(in srgb, #f59e0b 10%, var(--bg2))); + --se-palette-range-exclude-accent: #f87171; + --se-palette-range-exclude-bg: linear-gradient(145deg, color-mix(in srgb, #f87171 20%, var(--bg2)), color-mix(in srgb, #f87171 9%, var(--bg2))); --se-palette-section-logic: #4dabf7; --se-palette-section-band: #a78bfa; --se-palette-section-ind: #34d399; + --se-palette-section-range: #f59e0b; --se-tab-active: var(--se-gold); --se-handle: var(--accent); --se-edge: color-mix(in srgb, var(--accent) 72%, transparent); @@ -202,10 +207,15 @@ --se-palette-band-accent: #7b1fa2; --se-palette-band-bg: rgba(123, 31, 162, 0.07); --se-palette-ind-accent: #2e7d32; - --se-palette-ind-bg: rgba(46, 125, 50, 0.08); + --se-palette-ind-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.16), rgba(46, 125, 50, 0.06)); + --se-palette-range-accent: #e65100; + --se-palette-range-bg: linear-gradient(145deg, rgba(230, 81, 0, 0.16), rgba(230, 81, 0, 0.06)); + --se-palette-range-exclude-accent: #c62828; + --se-palette-range-exclude-bg: linear-gradient(145deg, rgba(198, 40, 40, 0.14), rgba(198, 40, 40, 0.05)); --se-palette-section-logic: #1565c0; --se-palette-section-band: #7b1fa2; --se-palette-section-ind: #2e7d32; + --se-palette-section-range: #e65100; --vtd-sig-gold-text: color-mix(in srgb, var(--se-gold) 88%, #212121); --vtd-sig-panel-bg: color-mix(in srgb, var(--se-gold) 5%, #ffffff); @@ -261,10 +271,15 @@ --se-palette-band-accent: #ce93d8; --se-palette-band-bg: rgba(206, 147, 216, 0.12); --se-palette-ind-accent: #69f0ae; - --se-palette-ind-bg: rgba(105, 240, 174, 0.1); + --se-palette-ind-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.18), rgba(105, 240, 174, 0.07)); + --se-palette-range-accent: #ffb74d; + --se-palette-range-bg: linear-gradient(145deg, rgba(255, 183, 77, 0.22), rgba(255, 183, 77, 0.08)); + --se-palette-range-exclude-accent: #ef5350; + --se-palette-range-exclude-bg: linear-gradient(145deg, rgba(239, 83, 80, 0.18), rgba(239, 83, 80, 0.07)); --se-palette-section-logic: #5eb5ff; --se-palette-section-band: #ce93d8; --se-palette-section-ind: #69f0ae; + --se-palette-section-range: #ffb74d; } /* Light theme — flow node & handle tweaks */ diff --git a/frontend/src/types/uiPreferences.ts b/frontend/src/types/uiPreferences.ts index 9557832..03e67e1 100644 --- a/frontend/src/types/uiPreferences.ts +++ b/frontend/src/types/uiPreferences.ts @@ -4,6 +4,7 @@ import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage'; import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage'; import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage'; +import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage'; /** gc_app_settings.ui_preferences_json 구조 */ export interface UiPreferences { @@ -16,6 +17,7 @@ export interface UiPreferences { userTemplates?: UserStrategyTemplate[]; /** 내장 템플릿 목록에서 숨긴 ID (getBuiltinTemplateId) */ hiddenBuiltinTemplateIds?: string[]; + paletteSideways?: SidewaysFilterPaletteEntry[]; }; indicator?: { customTabs?: IndicatorCustomTab[]; diff --git a/frontend/src/utils/sidewaysFilterPalette.ts b/frontend/src/utils/sidewaysFilterPalette.ts index 982a511..bd848fb 100644 --- a/frontend/src/utils/sidewaysFilterPalette.ts +++ b/frontend/src/utils/sidewaysFilterPalette.ts @@ -5,6 +5,7 @@ import type { LogicNode } from './strategyTypes'; import type { DefType } from './strategyEditorShared'; import { genId } from './strategyNodeIds'; +import type { SidewaysFilterPaletteEntry } from './sidewaysFilterPaletteStorage'; import { THRESHOLD_HL_MID, THRESHOLD_HL_UNDER } from './thresholdSymbols'; export type SidewaysFilterMode = 'include' | 'exclude'; @@ -28,7 +29,9 @@ export interface SidewaysFilterItem { category: SidewaysFilterCategory; /** include=횡보 구간만, exclude=횡보 제외(추세만) */ mode: SidewaysFilterMode; - build: (def: DefType) => LogicNode; + builtIn?: boolean; + /** 모드(NOT) 적용 전 내부 조건 트리 */ + buildInner: (def: DefType) => LogicNode; } export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record = { @@ -158,14 +161,6 @@ function dcNarrow(period: number, threshold: number): LogicNode { ); } -function buildInclude(inner: LogicNode): LogicNode { - return inner; -} - -function buildExclude(inner: LogicNode): LogicNode { - return notNode(inner); -} - function item( id: string, label: string, @@ -180,13 +175,25 @@ function item( desc, category, mode, - build: def => { - const node = typeof inner === 'function' ? inner(def) : inner; - return mode === 'exclude' ? buildExclude(node) : buildInclude(node); - }, + builtIn: true, + buildInner: def => (typeof inner === 'function' ? inner(def) : inner), }; } +export function applySidewaysFilterMode(inner: LogicNode, mode: SidewaysFilterMode): LogicNode { + return mode === 'exclude' ? notNode(inner) : inner; +} + +export function buildSidewaysFilterLogic( + templateId: string, + mode: SidewaysFilterMode, + def: DefType, +): LogicNode | null { + const tpl = BUILTIN_FILTER_MAP.get(templateId); + if (!tpl) return null; + return applySidewaysFilterMode(tpl.buildInner(def), mode); +} + /** 내장 횡보 필터 목록 */ export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [ // ── ADX ── @@ -299,23 +306,17 @@ export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [ )), ]; -const FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i])); +const BUILTIN_FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i])); export function getSidewaysFilterItem(id: string): SidewaysFilterItem | undefined { - return FILTER_MAP.get(id); -} - -export function buildSidewaysFilterNode(id: string, def: DefType): LogicNode | null { - const item = getSidewaysFilterItem(id); - if (!item) return null; - return item.build(def); + return BUILTIN_FILTER_MAP.get(id); } export function filterSidewaysItems( - items: SidewaysFilterItem[], + items: SidewaysFilterPaletteEntry[], query: string, modeFilter: 'all' | SidewaysFilterMode, -): SidewaysFilterItem[] { +): SidewaysFilterPaletteEntry[] { const q = query.trim().toLowerCase(); return items.filter(i => { if (modeFilter !== 'all' && i.mode !== modeFilter) return false; @@ -332,12 +333,12 @@ export function filterSidewaysItems( } export function groupSidewaysFiltersByCategory( - items: SidewaysFilterItem[], -): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterItem[] }[] { + items: SidewaysFilterPaletteEntry[], +): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterPaletteEntry[] }[] { const order: SidewaysFilterCategory[] = [ 'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'cycle', 'dmi', 'volume', 'combo', ]; - const grouped = new Map(); + const grouped = new Map(); for (const i of items) { const list = grouped.get(i.category) ?? []; list.push(i); diff --git a/frontend/src/utils/sidewaysFilterPaletteStorage.ts b/frontend/src/utils/sidewaysFilterPaletteStorage.ts new file mode 100644 index 0000000..d3513b0 --- /dev/null +++ b/frontend/src/utils/sidewaysFilterPaletteStorage.ts @@ -0,0 +1,102 @@ +/** 횡보필터 팔레트 — DB ui_preferences 저장 */ +import type { LogicNode } from './strategyTypes'; +import type { DefType } from './strategyEditorShared'; +import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; +import { + DEFAULT_SIDEWAYS_FILTER_ITEMS, + buildSidewaysFilterLogic, + type SidewaysFilterCategory, + type SidewaysFilterMode, +} from './sidewaysFilterPalette'; + +export interface SidewaysFilterPaletteEntry { + id: string; + label: string; + desc: string; + category: SidewaysFilterCategory; + mode: SidewaysFilterMode; + /** 내장 필터 템플릿 ID — 로직 생성에 사용 */ + templateId: string; + builtIn?: boolean; +} + +function defaultEntries(): SidewaysFilterPaletteEntry[] { + return DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => ({ + id: i.id, + templateId: i.id, + label: i.label, + desc: i.desc, + category: i.category, + mode: i.mode, + builtIn: true, + })); +} + +function parseStored(raw: unknown): SidewaysFilterPaletteEntry[] | null { + if (!Array.isArray(raw)) return null; + return raw.filter( + (e): e is SidewaysFilterPaletteEntry => + e != null + && typeof e === 'object' + && typeof (e as SidewaysFilterPaletteEntry).id === 'string' + && typeof (e as SidewaysFilterPaletteEntry).templateId === 'string' + && typeof (e as SidewaysFilterPaletteEntry).label === 'string', + ); +} + +function mergeMissingBuiltins( + stored: SidewaysFilterPaletteEntry[], + defaults: SidewaysFilterPaletteEntry[], +): SidewaysFilterPaletteEntry[] { + const existing = new Set(stored.map(i => i.id)); + const added = defaults.filter(d => !existing.has(d.id)); + return added.length > 0 ? [...stored, ...added] : stored; +} + +export function loadSidewaysPaletteItems(): SidewaysFilterPaletteEntry[] { + const raw = getUiPreferences().strategyEditor?.paletteSideways; + const stored = parseStored(raw); + const defaults = defaultEntries(); + if (!stored?.length) return defaults; + return mergeMissingBuiltins(stored, defaults); +} + +export function saveSidewaysPaletteItems(items: SidewaysFilterPaletteEntry[]): void { + patchUiPreferences({ strategyEditor: { paletteSideways: items } }); +} + +export function createSidewaysFilterPaletteId(): string { + return `range-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function buildSidewaysFilterNode( + id: string, + def: DefType, + entries?: SidewaysFilterPaletteEntry[], +): LogicNode | null { + const list = entries ?? loadSidewaysPaletteItems(); + const entry = list.find(e => e.id === id); + if (!entry) return null; + return buildSidewaysFilterLogic(entry.templateId, entry.mode, def); +} + +export const SIDEWAYS_TEMPLATE_OPTIONS = DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => ({ + value: i.id, + label: i.label, + category: i.category, + mode: i.mode, + desc: i.desc, +})); + +export const SIDEWAYS_CATEGORY_OPTIONS: { value: SidewaysFilterCategory; label: string }[] = [ + { value: 'adx', label: 'ADX · 추세강도' }, + { value: 'bollinger', label: '볼린저 밴드' }, + { value: 'ma', label: 'MA · 이동평균' }, + { value: 'ichimoku', label: '일목균형표' }, + { value: 'donchian', label: 'Donchian · 채널' }, + { value: 'oscillator', label: '오실레이터' }, + { value: 'cycle', label: 'Stoch·lookback' }, + { value: 'dmi', label: 'DMI' }, + { value: 'volume', label: '거래량 · VR' }, + { value: 'combo', label: '복합 필터' }, +];