From 0544971fd8e8a276a5e21b63036e42e1fcf24205 Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 24 Jun 2026 01:25:28 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=84=EB=9E=B5=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EB=AA=A9=EB=A1=9D=ED=83=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 70 ++----- .../src/components/StrategyEvaluationPage.tsx | 54 +++++- .../StrategyEvaluationTemplateTab.tsx | 85 +++++++++ .../src/hooks/useStrategyEvaluationEditor.ts | 38 ++++ frontend/src/styles/strategyEvaluation.css | 38 ++++ .../utils/strategyEvaluationTemplateApply.ts | 176 ++++++++++++++++++ frontend/src/utils/strategyTemplateList.ts | 89 +++++++++ 7 files changed, 495 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/strategyEvaluation/StrategyEvaluationTemplateTab.tsx create mode 100644 frontend/src/utils/strategyEvaluationTemplateApply.ts create mode 100644 frontend/src/utils/strategyTemplateList.ts diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 7f09eb1..aac1958 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -55,9 +55,9 @@ import { type PaletteItem, } from '../utils/strategyPaletteStorage'; import { - buildCompositePaletteTemplateRows, - buildSidewaysPaletteTemplateRows, -} from '../utils/strategyEditorTemplatePaletteRows'; + buildStrategyTemplatePaletteRows, + filterStrategyTemplatePaletteRows, +} from '../utils/strategyTemplateList'; import { buildPriceExtremeBreakoutTree, isPriceExtremeBreakoutPairPaletteValue, @@ -125,8 +125,6 @@ import { type StrategyEditorMode, } from '../utils/strategyEditorModeStorage'; import { - getStrategyTemplates, - getBuiltinTemplateId, simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; @@ -1398,55 +1396,21 @@ export default function StrategyEditorPage({ scheduleStrategyPersist(); }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]); - const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); + const templateRows = useMemo( + () => buildStrategyTemplatePaletteRows({ + def: DEF, + userTemplates, + hiddenBuiltinTemplateIds, + compositePalette, + sidewaysPalette, + }), + [DEF, userTemplates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette], + ); - const templateRows = useMemo(() => { - const userRows: TemplatePaletteRow[] = userTemplates.map(t => ({ - key: t.id, - source: 'user', - label: t.label, - description: t.description, - signal: t.signal, - user: t, - })); - const hidden = new Set(hiddenBuiltinTemplateIds); - const builtinRows: TemplatePaletteRow[] = templates - .filter(t => !hidden.has(getBuiltinTemplateId(t))) - .map(t => { - const builtinId = getBuiltinTemplateId(t); - return { - key: builtinId, - source: 'builtin', - label: t.label, - description: 'description' in t ? t.description : undefined, - signal: t.signal, - builtinId, - builtin: t, - }; - }); - const compositeRows: TemplatePaletteRow[] = buildCompositePaletteTemplateRows(compositePalette, hidden); - const sidewaysRows: TemplatePaletteRow[] = buildSidewaysPaletteTemplateRows(sidewaysPalette, hidden); - return [...userRows, ...builtinRows, ...compositeRows, ...sidewaysRows]; - }, [userTemplates, templates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette]); - - const filteredTemplateRows = useMemo(() => { - const q = templateSearch.trim().toLowerCase(); - if (!q) return templateRows; - return templateRows.filter(row => { - const signalLabel = row.signal === 'buy' ? '매수' : '매도'; - const badge = row.source === 'user' ? '내 템플릿' - : row.source === 'composite-palette' ? '복합지표' - : row.source === 'sideways-palette' ? '횡보지표' - : ''; - return ( - row.label.toLowerCase().includes(q) - || row.description?.toLowerCase().includes(q) - || signalLabel.includes(q) - || row.signal.includes(q) - || badge.includes(q) - ); - }); - }, [templateRows, templateSearch]); + const filteredTemplateRows = useMemo( + () => filterStrategyTemplatePaletteRows(templateRows, templateSearch), + [templateRows, templateSearch], + ); const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => { if (editorMode === 'graph') layoutFlushRef.current?.(); diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 267d060..7f55329 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -28,6 +28,7 @@ import StrategyEvaluationStrategyPanel from './strategyEvaluation/StrategyEvalua import StrategyEvaluationIndicatorPalettePanel from './strategyEvaluation/StrategyEvaluationIndicatorPalettePanel'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import StrategyEvaluationMarketTab from './strategyEvaluation/StrategyEvaluationMarketTab'; +import StrategyEvaluationTemplateTab from './strategyEvaluation/StrategyEvaluationTemplateTab'; import { usePageRefresh } from '../utils/pageRefreshEvents'; import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; @@ -87,6 +88,12 @@ import { import type { StrategyEvaluationAiVerifyResponse } from '../utils/backendApi'; import { useStrategyBulkEvaluation } from '../hooks/useStrategyBulkEvaluation'; import StrategyBulkEvalBadge, { StrategyBulkEvalToolbar } from './strategyEvaluation/StrategyBulkEvalControls'; +import { + buildStrategyTemplatePaletteRows, + filterStrategyTemplatePaletteRows, +} from '../utils/strategyTemplateList'; +import type { TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; +import { buildStrategyEditorDefFromSettings } from '../utils/strategyEditorShared'; const LEFT_KEY = 'seval-left-width'; const LEFT_OPEN_KEY = 'seval-left-open'; @@ -99,7 +106,7 @@ const STRATEGY_PANEL_MIN = 160; const STRATEGY_PANEL_MAX = 520; const STRATEGY_PANEL_DEFAULT = 280; -type LeftTab = 'strategy' | 'market' | 'settings'; +type LeftTab = 'strategy' | 'template' | 'market' | 'settings'; type RightTab = 'signal' | 'indicators' | 'chart' | 'ai'; function readMinWidth(key: string, fallback: number): number { @@ -141,6 +148,8 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { const [marketSearchOpen, setMarketSearchOpen] = useState(false); const [strategies, setStrategies] = useState([]); const [strategySearch, setStrategySearch] = useState(''); + const [templateSearch, setTemplateSearch] = useState(''); + const [templateApplyingKey, setTemplateApplyingKey] = useState(null); const [strategyListSort, setStrategyListSort] = useState(loadStrategyListSort); const [selectedStrategyId, setSelectedStrategyId] = useState(null); const [selectedStrategy, setSelectedStrategy] = useState(null); @@ -237,6 +246,21 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { onStrategySaved: handleStrategyEditorSaved, }); + const templateDef = useMemo( + () => buildStrategyEditorDefFromSettings(baseGetParams, getVisualConfig), + [baseGetParams, getVisualConfig, settingsRevision], + ); + + const templateRows = useMemo( + () => buildStrategyTemplatePaletteRows({ def: templateDef }), + [templateDef, settingsRevision], + ); + + const filteredTemplateRows = useMemo( + () => filterStrategyTemplatePaletteRows(templateRows, templateSearch), + [templateRows, templateSearch], + ); + const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT)); const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true)); const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT)); @@ -636,6 +660,15 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { setLeftTab('strategy'); }, []); + const handleApplyTemplate = useCallback(async (row: TemplatePaletteRow) => { + setTemplateApplyingKey(row.key); + try { + await strategyEditor.applyTemplateForEvaluation(row); + } finally { + setTemplateApplyingKey(null); + } + }, [strategyEditor]); + const handleSelectStrategyById = useCallback((id: number) => { const found = strategies.find(s => s.id === id); if (found) { @@ -933,6 +966,15 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { > 전략 + + )} + +
+ {rows.length === 0 ? ( +

템플릿이 없습니다

+ ) : ( + rows.map(row => { + const sourceBadge = templateBadgeForSource(row.source); + const applying = applyingKey === row.key; + return ( + + ); + }) + )} +
+ +); + +export default StrategyEvaluationTemplateTab; diff --git a/frontend/src/hooks/useStrategyEvaluationEditor.ts b/frontend/src/hooks/useStrategyEvaluationEditor.ts index 9dbfb09..ec6b080 100644 --- a/frontend/src/hooks/useStrategyEvaluationEditor.ts +++ b/frontend/src/hooks/useStrategyEvaluationEditor.ts @@ -58,6 +58,11 @@ import { import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import type { IndicatorVisualConfig } from './useIndicatorSettings'; import type { PaletteItem } from '../utils/strategyPaletteStorage'; +import type { TemplatePaletteRow } from '../components/strategyEditor/TemplatePaletteTab'; +import { + buildTemplateEvaluationPayload, + suggestTemplateEvaluationStrategyName, +} from '../utils/strategyEvaluationTemplateApply'; const AUTOSAVE_MS = 400; @@ -478,6 +483,38 @@ export function useStrategyEvaluationEditor({ }); }, [def, handleEditorStateChange]); + const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise => { + const payload = buildTemplateEvaluationPayload(row, def); + if (!payload) { + showSaveToast('템플릿을 적용할 수 없습니다'); + return null; + } + + setBuyEditorState(payload.buy); + setSellEditorState(payload.sell); + setBuyOrphans(payload.buyOrphans); + setSellOrphans(payload.sellOrphans); + setFlowLayout(payload.flowLayout); + setSignalTab(payload.preferredTab); + loadedStrategyIdRef.current = null; + + buyEditorStateRef.current = payload.buy; + sellEditorStateRef.current = payload.sell; + buyOrphansRef.current = payload.buyOrphans; + sellOrphansRef.current = payload.sellOrphans; + flowLayoutRef.current = payload.flowLayout; + + const existingNames = new Set(strategiesRef.current.map(s => s.name.trim())); + const name = suggestTemplateEvaluationStrategyName(row.label, existingNames); + + return flushPersist({ + asNew: true, + name, + description: payload.strategyDescription, + silent: false, + }); + }, [def, flushPersist, showSaveToast]); + const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { if (tab === signalTab) return; void flushPersist(); @@ -497,6 +534,7 @@ export function useStrategyEvaluationEditor({ applyPalette, applyPaletteItem, applySidewaysFilter, + applyTemplateForEvaluation, saving, saveNow, openSaveDialog, diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 49ee628..1e18d85 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -3292,3 +3292,41 @@ html.theme-light .seval-analyze-btn:hover:not(:disabled), border-color: rgba(25, 118, 210, 0.45); background: rgba(25, 118, 210, 0.08); } + +/* 좌측 — 전략템플릿 탭 */ +.seval-template-scroll { + padding-top: 2px; +} +.seval-template-item .btd-exec-item-top { + align-items: flex-start; + gap: 8px; +} +.seval-template-item-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + min-width: 0; +} +.seval-template-item-desc { + margin-top: 4px; +} +.seval-template-item-status { + margin-top: 4px; + color: var(--se-gold, #e6c200); +} +.seval-template-item--applying { + border-color: color-mix(in srgb, var(--se-gold) 45%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-gold) 25%, transparent); +} +.seval-template-item .se-template-source-badge { + font-size: 0.58rem; + padding: 1px 5px; +} +.seval-template-item .se-template-signal { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 700; + padding: 2px 6px; + border-radius: 999px; +} diff --git a/frontend/src/utils/strategyEvaluationTemplateApply.ts b/frontend/src/utils/strategyEvaluationTemplateApply.ts new file mode 100644 index 0000000..267a75e --- /dev/null +++ b/frontend/src/utils/strategyEvaluationTemplateApply.ts @@ -0,0 +1,176 @@ +/** + * 전략평가 — 템플릿 선택 시 편집 상태·저장용 payload 구성 + */ +import type { TemplatePaletteRow } from '../components/strategyEditor/TemplatePaletteTab'; +import type { LogicNode } from './strategyTypes'; +import type { DefType } from './strategyEditorShared'; +import { makeNode } from './strategyEditorShared'; +import { simpleTemplateToNode } from './strategyPresets'; +import { + buildStochOverboughtPairTree, + isStochOverboughtPairPaletteValue, +} from './stochOverboughtPair'; +import { + buildPriceExtremeBreakoutTree, + isPriceExtremeBreakoutPairPaletteValue, +} from './priceExtremeBreakoutPair'; +import { + buildInflection33Tree, + isInflection33PaletteValue, +} from './inflection33Strategy'; +import { + buildIchimokuBbTree, + isIchimokuBbPaletteValue, +} from './ichimokuBbStrategy'; +import { + buildStableStrategyTree, + isStableStrategyPaletteValue, +} from './stableStrategyPairs'; +import { + buildSidewaysFilterNode, + loadSidewaysPaletteItems, +} from './sidewaysFilterPaletteStorage'; +import type { PaletteItem } from './strategyPaletteStorage'; +import { + decodeConditionForEditor, + emptyEditorConditionState, + normalizeStartCombineOp, + type EditorConditionState, +} from './strategyConditionSerde'; +import { + buildStrategyFlowLayoutStore, + emptySignalFlowLayout, + emptyStrategyFlowLayout, + type StrategyFlowLayoutStore, +} from './strategyEditorLayoutStorage'; + +export type TemplateEvaluationPayload = { + buy: EditorConditionState; + sell: EditorConditionState; + buyOrphans: LogicNode[]; + sellOrphans: LogicNode[]; + flowLayout: StrategyFlowLayoutStore; + preferredTab: 'buy' | 'sell'; + strategyName: string; + strategyDescription: string; +}; + +function buildLogicNodeFromPaletteItem( + item: PaletteItem, + signalTab: 'buy' | 'sell', + def: DefType, +): LogicNode | null { + const stochPair = isStochOverboughtPairPaletteValue(item.value); + const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value); + const inflection33 = isInflection33PaletteValue(item.value); + const ichimokuBb = isIchimokuBbPaletteValue(item.value); + const stableStrategy = isStableStrategyPaletteValue(item.value); + const composite = item.kind === 'composite' + && !stochPair + && !priceExtremeBreakout + && !inflection33 + && !ichimokuBb + && !stableStrategy; + + if (stochPair) return buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', def); + if (priceExtremeBreakout) return buildPriceExtremeBreakoutTree(item.value, def); + if (inflection33) return buildInflection33Tree(item.value, def); + if (ichimokuBb) return buildIchimokuBbTree(item.value, def); + if (stableStrategy) return buildStableStrategyTree(item.value, def); + return makeNode('indicator', item.value, signalTab, def, composite ? { composite: true } : undefined); +} + +function layoutFromEditorState(state: EditorConditionState, orphans: LogicNode[] = []) { + return { + positions: {}, + edgeHandles: {}, + orphans, + startMeta: state.startMeta, + extraStartIds: state.extraStartIds, + extraRoots: state.extraRoots, + startCombineOp: normalizeStartCombineOp(state.startCombineOp), + }; +} + +function payloadForSide( + signal: 'buy' | 'sell', + editorState: EditorConditionState, + orphans: LogicNode[], + row: TemplatePaletteRow, +): TemplateEvaluationPayload { + const emptyBuy = emptyEditorConditionState(); + const emptySell = emptyEditorConditionState(); + const buy = signal === 'buy' ? editorState : emptyBuy; + const sell = signal === 'sell' ? editorState : emptySell; + const buyOrphans = signal === 'buy' ? orphans : []; + const sellOrphans = signal === 'sell' ? orphans : []; + const buyLayout = signal === 'buy' + ? layoutFromEditorState(editorState, buyOrphans) + : emptySignalFlowLayout(); + const sellLayout = signal === 'sell' + ? layoutFromEditorState(editorState, sellOrphans) + : emptySignalFlowLayout(); + + return { + buy, + sell, + buyOrphans, + sellOrphans, + flowLayout: buildStrategyFlowLayoutStore(buyLayout, sellLayout, buyOrphans, sellOrphans), + preferredTab: signal, + strategyName: `[템플릿] ${row.label}`, + strategyDescription: row.description?.trim() || `전략 템플릿: ${row.label}`, + }; +} + +export function buildTemplateEvaluationPayload( + row: TemplatePaletteRow, + def: DefType, +): TemplateEvaluationPayload | null { + if (row.source === 'user' && row.user) { + const t = row.user; + const editorState: EditorConditionState = { + root: t.editorState.root, + startMeta: t.editorState.startMeta, + extraStartIds: t.editorState.extraStartIds, + extraRoots: t.editorState.extraRoots ?? {}, + startCombineOp: normalizeStartCombineOp(t.editorState.startCombineOp), + }; + return payloadForSide(t.signal, editorState, t.orphans ?? [], row); + } + + if (row.source === 'builtin' && row.builtin) { + const root = row.builtin.kind === 'composite' + ? row.builtin.build() + : simpleTemplateToNode(row.builtin); + const editorState = decodeConditionForEditor(root); + return payloadForSide(row.builtin.signal, editorState, [], row); + } + + if (row.source === 'composite-palette' && row.paletteItem) { + const root = buildLogicNodeFromPaletteItem(row.paletteItem, row.signal, def); + if (!root) return null; + const editorState = decodeConditionForEditor(root); + return payloadForSide(row.signal, editorState, [], row); + } + + if (row.source === 'sideways-palette' && row.sidewaysFilterId) { + const root = buildSidewaysFilterNode(row.sidewaysFilterId, def, loadSidewaysPaletteItems()); + if (!root) return null; + const editorState = decodeConditionForEditor(root); + return payloadForSide(row.signal, editorState, [], row); + } + + return null; +} + +export function suggestTemplateEvaluationStrategyName( + baseLabel: string, + existingNames: ReadonlySet, +): string { + const base = `[템플릿] ${baseLabel.trim() || '새 전략'}`; + if (!existingNames.has(base)) return base; + let n = 2; + while (existingNames.has(`${base} (${n})`)) n += 1; + return `${base} (${n})`; +} diff --git a/frontend/src/utils/strategyTemplateList.ts b/frontend/src/utils/strategyTemplateList.ts new file mode 100644 index 0000000..5c1f57c --- /dev/null +++ b/frontend/src/utils/strategyTemplateList.ts @@ -0,0 +1,89 @@ +/** + * 전략편집기·전략평가 — 템플릿 팔레트 목록 (공통) + */ +import type { TemplatePaletteRow } from '../components/strategyEditor/TemplatePaletteTab'; +import type { DefType } from './strategyEditorShared'; +import { + getBuiltinTemplateId, + getStrategyTemplates, +} from './strategyPresets'; +import { + buildCompositePaletteTemplateRows, + buildSidewaysPaletteTemplateRows, +} from './strategyEditorTemplatePaletteRows'; +import { + loadHiddenBuiltinTemplateIds, + loadUserStrategyTemplates, + type UserStrategyTemplate, +} from './strategyTemplateStorage'; +import { loadPaletteItems, type PaletteItem } from './strategyPaletteStorage'; +import { loadSidewaysPaletteItems, type SidewaysFilterPaletteEntry } from './sidewaysFilterPaletteStorage'; + +export function buildStrategyTemplatePaletteRows(opts: { + def: DefType; + userTemplates?: UserStrategyTemplate[]; + hiddenBuiltinTemplateIds?: string[]; + compositePalette?: PaletteItem[]; + sidewaysPalette?: SidewaysFilterPaletteEntry[]; +}): TemplatePaletteRow[] { + const { + def, + userTemplates = loadUserStrategyTemplates(), + hiddenBuiltinTemplateIds = loadHiddenBuiltinTemplateIds(), + compositePalette = loadPaletteItems('composite'), + sidewaysPalette = loadSidewaysPaletteItems(), + } = opts; + + const userRows: TemplatePaletteRow[] = userTemplates.map(t => ({ + key: t.id, + source: 'user', + label: t.label, + description: t.description, + signal: t.signal, + user: t, + })); + + const hidden = new Set(hiddenBuiltinTemplateIds); + const templates = getStrategyTemplates(def); + const builtinRows: TemplatePaletteRow[] = templates + .filter(t => !hidden.has(getBuiltinTemplateId(t))) + .map(t => { + const builtinId = getBuiltinTemplateId(t); + return { + key: builtinId, + source: 'builtin', + label: t.label, + description: 'description' in t ? t.description : undefined, + signal: t.signal, + builtinId, + builtin: t, + }; + }); + + const compositeRows: TemplatePaletteRow[] = buildCompositePaletteTemplateRows(compositePalette, hidden); + const sidewaysRows: TemplatePaletteRow[] = buildSidewaysPaletteTemplateRows(sidewaysPalette, hidden); + + return [...userRows, ...builtinRows, ...compositeRows, ...sidewaysRows]; +} + +export function filterStrategyTemplatePaletteRows( + rows: TemplatePaletteRow[], + searchQuery: string, +): TemplatePaletteRow[] { + const q = searchQuery.trim().toLowerCase(); + if (!q) return rows; + return rows.filter(row => { + const signalLabel = row.signal === 'buy' ? '매수' : '매도'; + const badge = row.source === 'user' ? '내 템플릿' + : row.source === 'composite-palette' ? '복합지표' + : row.source === 'sideways-palette' ? '횡보지표' + : ''; + return ( + row.label.toLowerCase().includes(q) + || row.description?.toLowerCase().includes(q) + || signalLabel.includes(q) + || row.signal.includes(q) + || badge.includes(q) + ); + }); +}