From d5e864b2305fad08de47ae5057894e29347b1335 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 16 Jun 2026 10:48:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20=EA=B8=B0=EB=8A=A5=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 | 44 +++- .../src/components/StrategyEvaluationPage.tsx | 87 ++++-- .../StrategyEditorModal.tsx | 50 ++++ .../StrategyEvaluationSettingsTab.tsx | 133 +++++----- .../StrategyEvaluationStrategyDetail.tsx | 150 +++++++++++ frontend/src/styles/strategyEditor.css | 15 ++ frontend/src/styles/strategyEvaluation.css | 249 ++++++++++++++++++ 7 files changed, 644 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/strategyEvaluation/StrategyEditorModal.tsx create mode 100644 frontend/src/components/strategyEvaluation/StrategyEvaluationStrategyDetail.tsx diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 9865ca4..bfcf43e 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -191,6 +191,11 @@ function readRightPanelWidth(): number { interface Props { theme: Theme; onNavigateToBacktest?: () => void; + /** 팝업(임베드) — 전략평가 등 */ + variant?: 'page' | 'modal'; + onClose?: () => void; + initialStrategyId?: number | null; + onStrategiesChanged?: () => void; } function toEditorState( @@ -262,13 +267,25 @@ function isDuplicateStrategyName( return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId); } -export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) { +export default function StrategyEditorPage({ + theme, + onNavigateToBacktest, + variant = 'page', + onClose, + initialStrategyId = null, + onStrategiesChanged, +}: Props) { const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const DEF = useMemo( () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), [getParams, getVisualConfig, settingsRevision], ); const settingsSyncRef = useRef(-1); + const initialStrategyAppliedRef = useRef(false); + + const notifyStrategiesChanged = useCallback(() => { + onStrategiesChanged?.(); + }, [onStrategiesChanged]); const [strategies, setStrategies] = useState(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(null); @@ -953,6 +970,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop })(); }; + useEffect(() => { + if (variant !== 'modal' || initialStrategyId == null || initialStrategyAppliedRef.current) return; + if (strategies.length === 0) return; + const found = strategies.find(s => s.id === initialStrategyId); + if (!found) return; + initialStrategyAppliedRef.current = true; + handleSelectStrategy(found); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [variant, initialStrategyId, strategies]); + const handleNew = () => { setSelectedId(null); setStratName(''); @@ -1045,6 +1072,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop } else { showSnack('전략이 저장되었습니다'); } + notifyStrategiesChanged(); } catch (e) { showSnack(e instanceof Error ? e.message : '저장 실패', false); } finally { @@ -1061,6 +1089,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop setDeleteOpen(false); setDeleteId(null); showSnack('전략이 삭제되었습니다'); + notifyStrategiesChanged(); }; const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => { @@ -1617,7 +1646,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop selectedPaletteKey === paletteKey(type, id); return ( -
+
{needsPointerPaletteDrag() ? : null} {saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
@@ -1711,6 +1740,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
+ {variant === 'modal' && onClose && ( + + )} - +
+
+ + +
+ {leftTab === 'strategy' && ( + + )}
{leftTab === 'strategy' ? ( @@ -487,6 +525,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
+ + {editorModalOpen && ( + { void refreshStrategies(); }} + /> + )}
); } diff --git a/frontend/src/components/strategyEvaluation/StrategyEditorModal.tsx b/frontend/src/components/strategyEvaluation/StrategyEditorModal.tsx new file mode 100644 index 0000000..3cb3175 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEditorModal.tsx @@ -0,0 +1,50 @@ +/** + * 전략 평가 — 전략편집기 전체 화면 팝업 + */ +import React, { Suspense, lazy, useEffect } from 'react'; +import type { Theme } from '../../types'; + +const StrategyEditorPage = lazy(() => import('../StrategyEditorPage')); + +interface Props { + theme: Theme; + initialStrategyId?: number | null; + onClose: () => void; + onStrategiesChanged?: () => void; +} + +export default function StrategyEditorModal({ + theme, + initialStrategyId = null, + onClose, + onStrategiesChanged, +}: Props) { + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + return ( +
+
+ 전략 편집기 불러오는 중…
}> + + +
+ + ); +} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx index 0bf66be..8c237e8 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx @@ -13,6 +13,7 @@ import { type EvalIndicatorParams, } from '../../utils/strategyEvaluationParams'; import { repairUtf8Mojibake } from '../../utils/textEncoding'; +import StrategyEvaluationStrategyDetail from './StrategyEvaluationStrategyDetail'; interface Props { strategy: StrategyDto | null; @@ -124,75 +125,83 @@ const StrategyEvaluationSettingsTab: React.FC = ({ return (
-
-
-

{strategyName}

+
+
+
+

{strategyName}

+ +
+

지표 값을 변경한 뒤 적용을 누르면 차트·일치율이 갱신됩니다

+
+ +
+ {indicatorTypes.map(type => { + const def = getIndicatorDef(type); + const params = draft[type] ?? {}; + const paramKeys = getEditableParamKeys(type, params); + const isOpen = expanded === type; + const label = def?.koreanName ?? def?.shortName ?? type; + + return ( +
+ + {isOpen && ( +
+ {paramKeys.map(key => ( + patchParam(type, k, raw)} + /> + ))} + +
+ )} +
+ ); + })} +
+ +
-

지표 값을 변경한 뒤 적용을 누르면 차트·일치율이 갱신됩니다

-
- {indicatorTypes.map(type => { - const def = getIndicatorDef(type); - const params = draft[type] ?? {}; - const paramKeys = getEditableParamKeys(type, params); - const isOpen = expanded === type; - const label = def?.koreanName ?? def?.shortName ?? type; - - return ( -
- - {isOpen && ( -
- {paramKeys.map(key => ( - patchParam(type, k, raw)} - /> - ))} - -
- )} -
- ); - })} -
- -
- -
+
); }; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationStrategyDetail.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationStrategyDetail.tsx new file mode 100644 index 0000000..93abaab --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationStrategyDetail.tsx @@ -0,0 +1,150 @@ +/** + * 전략 평가 — 전략설정 탭 하단: 조건식 + 상세 설명 + */ +import React, { useCallback, useMemo } from 'react'; +import type { StrategyDto } from '../../utils/backendApi'; +import type { LogicNode } from '../../utils/strategyTypes'; +import { asLogicNode } from '../../utils/strategyHydrate'; +import { decodeConditionForEditor } from '../../utils/strategyConditionSerde'; +import { buildStrategyNarrative } from '../../utils/strategyDescriptionNarrative'; +import { buildDefFromGetParams, nodeToFormula, nodeToText } from '../../utils/strategyEditorShared'; +import { repairUtf8Mojibake } from '../../utils/textEncoding'; +import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams'; + +interface Props { + strategy: StrategyDto; + getParams: (type: string) => Record; + appliedParams: EvalIndicatorParams | null; +} + +const StrategyEvaluationStrategyDetail: React.FC = ({ + strategy, + getParams, + appliedParams, +}) => { + const resolveGetParams = useCallback( + (type: string, defaults?: Record) => { + const base = getParams(type); + const merged = defaults ? { ...defaults, ...base } : base; + const override = appliedParams?.[type]; + return override ? { ...merged, ...override } : merged; + }, + [getParams, appliedParams], + ); + + const def = useMemo(() => buildDefFromGetParams(resolveGetParams), [resolveGetParams]); + + const buyCondition = useMemo( + () => asLogicNode(strategy.buyCondition), + [strategy.buyCondition], + ); + const sellCondition = useMemo( + () => asLogicNode(strategy.sellCondition), + [strategy.sellCondition], + ); + + const buyEditorState = useMemo( + () => decodeConditionForEditor(buyCondition), + [buyCondition], + ); + const sellEditorState = useMemo( + () => decodeConditionForEditor(sellCondition), + [sellCondition], + ); + + const narrative = useMemo( + () => buildStrategyNarrative({ + name: strategy.name, + description: strategy.description, + buyEditorState, + sellEditorState, + buyCondition, + sellCondition, + def, + }), + [ + strategy.name, + strategy.description, + buyEditorState, + sellEditorState, + buyCondition, + sellCondition, + def, + ], + ); + + const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`); + + const renderFormula = (label: string, tone: 'buy' | 'sell', root: LogicNode | null) => { + if (!root) return null; + return ( +
+
+ {label} +
+
{nodeToFormula(root, def)}
+

{nodeToText(root, def)}

+
+ ); + }; + + const hasConditions = Boolean(buyCondition || sellCondition); + + return ( +
+
+

전략 조건 · 설명

+ {strategyName} +
+ +
+
+

조건식

+ {!hasConditions ? ( +

등록된 매수·매도 조건이 없습니다.

+ ) : ( +
+ {renderFormula('매수 신호', 'buy', buyCondition)} + {renderFormula('매도 신호', 'sell', sellCondition)} +
+ )} +
+ +
+

상세 설명

+
+ {narrative.intro.map((p, i) => ( +

{p}

+ ))} + + {narrative.sections.map(section => ( +
+
{section.title}
+ {section.paragraphs.map((p, i) => ( +

{p}

+ ))} + {section.bullets && section.bullets.length > 0 && ( +
    + {section.bullets.map((line, i) => ( +
  • {line}
  • + ))} +
+ )} +
+ ))} + + {narrative.footnotes.length > 0 && ( + + )} +
+
+
+
+ ); +}; + +export default StrategyEvaluationStrategyDetail; diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 2c8f7bd..4581423 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -12,6 +12,21 @@ position: relative; } +.se-page--modal { + height: 100%; + min-height: 0; +} + +.se-btn--modal-close { + font-size: 1.35rem; + line-height: 1; + color: var(--se-text-muted); +} + +.se-btn--modal-close:hover { + color: var(--se-text); +} + .se-save-toast { position: absolute; top: 56px; diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index dd9fbe1..bde0ca2 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -80,6 +80,69 @@ height: 100%; } +.seval-left-tabs-row { + display: flex; + align-items: flex-end; + gap: 6px; + flex-shrink: 0; +} + +.seval-left-tabs-row .btd-exec-tabs { + flex: 1; + min-width: 0; +} + +.seval-strategy-add-btn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-bottom: 6px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 40%, var(--se-border)); + border-radius: 7px; + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 12%, var(--se-input-bg)); + color: var(--btd-gold, var(--se-gold)); + cursor: pointer; +} + +.seval-strategy-add-btn:hover { + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 22%, var(--se-input-bg)); +} + +.seval-editor-modal-overlay { + position: fixed; + inset: 0; + z-index: 12000; + display: flex; + align-items: stretch; + justify-content: center; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); +} + +.seval-editor-modal-shell { + flex: 1; + min-width: 0; + min-height: 0; + margin: 8px; + border-radius: 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--se-gold) 35%, transparent); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55); +} + +.seval-editor-modal-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--se-text-muted); + font-size: 0.85rem; +} + .seval-main-content { flex: 1; min-height: 0; @@ -969,6 +1032,14 @@ overflow: hidden; } +.seval-settings-top { + flex: 1 1 52%; + min-height: 120px; + display: flex; + flex-direction: column; + overflow: hidden; +} + .seval-settings-head { flex-shrink: 0; padding: 4px 2px 8px; @@ -1108,6 +1179,184 @@ cursor: not-allowed; } +/* ── 전략설정 탭 하단 — 조건식 · 상세 설명 ─────────────────────────────── */ +.seval-detail { + flex: 1 1 48%; + min-height: 160px; + max-height: 52%; + display: flex; + flex-direction: column; + overflow: hidden; + border-top: 1px solid var(--btd-divider, var(--se-border)); + background: color-mix(in srgb, var(--btd-surface, var(--se-palette-card-bg)) 88%, transparent); +} + +.seval-detail-head { + flex-shrink: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 8px 2px 6px; + border-bottom: 1px solid var(--btd-divider, var(--se-border)); +} + +.seval-detail-title { + margin: 0; + font-size: 0.72rem; + font-weight: 700; + color: var(--btd-gold, var(--se-gold)); + letter-spacing: 0.02em; +} + +.seval-detail-strategy { + font-size: 0.62rem; + color: var(--se-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.seval-detail-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 8px 2px 10px; +} + +.seval-detail-section + .seval-detail-section { + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed color-mix(in srgb, var(--btd-divider, var(--se-border)) 70%, transparent); +} + +.seval-detail-section-title { + margin: 0 0 8px; + font-size: 0.68rem; + font-weight: 700; + color: var(--se-text); +} + +.seval-detail-empty { + margin: 0; + font-size: 0.66rem; + color: var(--se-text-muted); + line-height: 1.4; +} + +.seval-detail-formulas { + display: flex; + flex-direction: column; + gap: 8px; +} + +.seval-detail-formula { + border-radius: 8px; + border: 1px solid var(--btd-divider, var(--se-border)); + background: var(--btd-surface, var(--se-input-bg)); + overflow: hidden; +} + +.seval-detail-formula--buy { + border-color: color-mix(in srgb, #4caf50 28%, var(--se-border)); +} + +.seval-detail-formula--sell { + border-color: color-mix(in srgb, #ef5350 28%, var(--se-border)); +} + +.seval-detail-formula-head { + padding: 6px 8px; + border-bottom: 1px solid var(--btd-divider, var(--se-border)); +} + +.seval-detail-formula-badge { + font-size: 0.64rem; + font-weight: 700; +} + +.seval-detail-formula-badge--buy { + color: #66bb6a; +} + +.seval-detail-formula-badge--sell { + color: #ef5350; +} + +.seval-detail-formula-expr { + margin: 0; + padding: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.62rem; + line-height: 1.45; + color: var(--se-text); + white-space: pre-wrap; + word-break: break-word; + background: rgba(0, 0, 0, 0.12); +} + +.seval-detail-formula-text { + margin: 0; + padding: 6px 8px 8px; + font-size: 0.64rem; + line-height: 1.45; + color: var(--se-text-muted); +} + +.seval-detail-narrative { + font-size: 0.64rem; + line-height: 1.5; + color: var(--se-text); +} + +.seval-detail-intro { + margin: 0 0 8px; + color: var(--se-text-muted); +} + +.seval-detail-block + .seval-detail-block { + margin-top: 10px; +} + +.seval-detail-block-title { + margin: 0 0 6px; + font-size: 0.66rem; + font-weight: 700; + color: var(--se-text); +} + +.seval-detail-para { + margin: 0 0 6px; +} + +.seval-detail-list { + margin: 4px 0 0; + padding-left: 0; + list-style: none; +} + +.seval-detail-list-item { + margin: 0 0 4px; + padding-left: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--se-text-muted); +} + +.seval-detail-footnotes { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--btd-divider, var(--se-border)); +} + +.seval-detail-footnote { + margin: 0 0 4px; + font-size: 0.6rem; + color: var(--se-text-muted); + line-height: 1.4; +} + /* ── 조건 분석보기 버튼 · 팝업 ─────────────────────────────────────────── */ .seval-analyze-btn { flex-shrink: 0;