전략편집기 팝업 기능 적용

This commit is contained in:
Macbook
2026-06-16 10:48:13 +09:00
parent 484f477d6c
commit d5e864b230
7 changed files with 644 additions and 84 deletions
+42 -2
View File
@@ -191,6 +191,11 @@ function readRightPanelWidth(): number {
interface Props { interface Props {
theme: Theme; theme: Theme;
onNavigateToBacktest?: () => void; onNavigateToBacktest?: () => void;
/** 팝업(임베드) — 전략평가 등 */
variant?: 'page' | 'modal';
onClose?: () => void;
initialStrategyId?: number | null;
onStrategiesChanged?: () => void;
} }
function toEditorState( function toEditorState(
@@ -262,13 +267,25 @@ function isDuplicateStrategyName(
return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId); 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 { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo( const DEF = useMemo(
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
[getParams, getVisualConfig, settingsRevision], [getParams, getVisualConfig, settingsRevision],
); );
const settingsSyncRef = useRef(-1); const settingsSyncRef = useRef(-1);
const initialStrategyAppliedRef = useRef(false);
const notifyStrategiesChanged = useCallback(() => {
onStrategiesChanged?.();
}, [onStrategiesChanged]);
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal()); const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(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 = () => { const handleNew = () => {
setSelectedId(null); setSelectedId(null);
setStratName(''); setStratName('');
@@ -1045,6 +1072,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
} else { } else {
showSnack('전략이 저장되었습니다'); showSnack('전략이 저장되었습니다');
} }
notifyStrategiesChanged();
} catch (e) { } catch (e) {
showSnack(e instanceof Error ? e.message : '저장 실패', false); showSnack(e instanceof Error ? e.message : '저장 실패', false);
} finally { } finally {
@@ -1061,6 +1089,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
setDeleteOpen(false); setDeleteOpen(false);
setDeleteId(null); setDeleteId(null);
showSnack('전략이 삭제되었습니다'); showSnack('전략이 삭제되었습니다');
notifyStrategiesChanged();
}; };
const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => { 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); selectedPaletteKey === paletteKey(type, id);
return ( return (
<div className={`se-page se-page--${theme}`}> <div className={`se-page se-page--${theme}${variant === 'modal' ? ' se-page--modal' : ''}`}>
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null} {needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
{saveToast && ( {saveToast && (
<div className="se-save-toast"> DB에 .</div> <div className="se-save-toast"> DB에 .</div>
@@ -1711,6 +1740,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div> </div>
<div className="se-header-actions"> <div className="se-header-actions">
{variant === 'modal' && onClose && (
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--modal-close"
title="닫기"
aria-label="전략 편집 닫기"
onClick={onClose}
>
×
</button>
)}
<button <button
type="button" type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--desc" className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
@@ -21,6 +21,7 @@ import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapsho
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel'; import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
@@ -75,6 +76,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [paramsRevision, setParamsRevision] = useState(0); const [paramsRevision, setParamsRevision] = useState(0);
const [chartLoading, setChartLoading] = useState(false); const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]); const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
const [editorModalOpen, setEditorModalOpen] = useState(false);
const evalSessionRef = useRef(0); const evalSessionRef = useRef(0);
const paramsRevisionRef = useRef(paramsRevision); const paramsRevisionRef = useRef(paramsRevision);
@@ -169,6 +171,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
void loadStrategies().then(list => setStrategies(list ?? [])); void loadStrategies().then(list => setStrategies(list ?? []));
}, []); }, []);
const refreshStrategies = useCallback(async () => {
const list = await loadStrategies();
const next = list ?? [];
setStrategies(next);
if (selectedStrategyId == null) return;
const still = next.find(s => s.id === selectedStrategyId);
if (still) {
setSelectedStrategy(hydrateStrategyDto(still));
return;
}
setSelectedStrategyId(null);
setSelectedStrategy(null);
setAppliedIndicatorParams(null);
setParamsRevision(v => v + 1);
}, [selectedStrategyId]);
const handleCloseStrategyEditor = useCallback(() => {
setEditorModalOpen(false);
void refreshStrategies();
}, [refreshStrategies]);
useEffect(() => { useEffect(() => {
if (!selectedStrategyId) { if (!selectedStrategyId) {
setSelectedStrategy(null); setSelectedStrategy(null);
@@ -308,6 +331,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
> >
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}> <aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
<div className="btd-exec-list seval-left-panel"> <div className="btd-exec-list seval-left-panel">
<div className="seval-left-tabs-row">
<div className="btd-exec-tabs" role="tablist"> <div className="btd-exec-tabs" role="tablist">
<button <button
type="button" type="button"
@@ -329,6 +353,20 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</button> </button>
</div> </div>
{leftTab === 'strategy' && (
<button
type="button"
className="seval-strategy-add-btn"
title="전략 추가·편집"
aria-label="전략 추가·편집"
onClick={() => setEditorModalOpen(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{leftTab === 'strategy' ? ( {leftTab === 'strategy' ? (
<> <>
@@ -487,6 +525,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</footer> </footer>
</aside> </aside>
</div> </div>
{editorModalOpen && (
<StrategyEditorModal
theme={theme}
initialStrategyId={selectedStrategyId}
onClose={handleCloseStrategyEditor}
onStrategiesChanged={() => { void refreshStrategies(); }}
/>
)}
</div> </div>
); );
} }
@@ -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 (
<div className="seval-editor-modal-overlay" role="dialog" aria-modal aria-label="전략 편집">
<div className="seval-editor-modal-shell">
<Suspense fallback={<div className="seval-editor-modal-loading"> </div>}>
<StrategyEditorPage
theme={theme}
variant="modal"
initialStrategyId={initialStrategyId}
onClose={onClose}
onStrategiesChanged={onStrategiesChanged}
/>
</Suspense>
</div>
</div>
);
}
@@ -13,6 +13,7 @@ import {
type EvalIndicatorParams, type EvalIndicatorParams,
} from '../../utils/strategyEvaluationParams'; } from '../../utils/strategyEvaluationParams';
import { repairUtf8Mojibake } from '../../utils/textEncoding'; import { repairUtf8Mojibake } from '../../utils/textEncoding';
import StrategyEvaluationStrategyDetail from './StrategyEvaluationStrategyDetail';
interface Props { interface Props {
strategy: StrategyDto | null; strategy: StrategyDto | null;
@@ -124,6 +125,7 @@ const StrategyEvaluationSettingsTab: React.FC<Props> = ({
return ( return (
<div className="seval-settings-tab"> <div className="seval-settings-tab">
<div className="seval-settings-top">
<div className="seval-settings-head"> <div className="seval-settings-head">
<div className="seval-settings-head-row"> <div className="seval-settings-head-row">
<p className="seval-settings-strategy">{strategyName}</p> <p className="seval-settings-strategy">{strategyName}</p>
@@ -194,6 +196,13 @@ const StrategyEvaluationSettingsTab: React.FC<Props> = ({
</button> </button>
</div> </div>
</div> </div>
<StrategyEvaluationStrategyDetail
strategy={strategy}
getParams={getParams}
appliedParams={appliedParams}
/>
</div>
); );
}; };
@@ -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<string, number | string | boolean>;
appliedParams: EvalIndicatorParams | null;
}
const StrategyEvaluationStrategyDetail: React.FC<Props> = ({
strategy,
getParams,
appliedParams,
}) => {
const resolveGetParams = useCallback(
(type: string, defaults?: Record<string, number | string | boolean>) => {
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 (
<div className={`seval-detail-formula seval-detail-formula--${tone}`}>
<div className="seval-detail-formula-head">
<span className={`seval-detail-formula-badge seval-detail-formula-badge--${tone}`}>{label}</span>
</div>
<pre className="seval-detail-formula-expr">{nodeToFormula(root, def)}</pre>
<p className="seval-detail-formula-text">{nodeToText(root, def)}</p>
</div>
);
};
const hasConditions = Boolean(buyCondition || sellCondition);
return (
<div className="seval-detail">
<div className="seval-detail-head">
<h3 className="seval-detail-title"> · </h3>
<span className="seval-detail-strategy">{strategyName}</span>
</div>
<div className="seval-detail-scroll">
<section className="seval-detail-section">
<h4 className="seval-detail-section-title"></h4>
{!hasConditions ? (
<p className="seval-detail-empty"> · .</p>
) : (
<div className="seval-detail-formulas">
{renderFormula('매수 신호', 'buy', buyCondition)}
{renderFormula('매도 신호', 'sell', sellCondition)}
</div>
)}
</section>
<section className="seval-detail-section">
<h4 className="seval-detail-section-title"> </h4>
<div className="seval-detail-narrative">
{narrative.intro.map((p, i) => (
<p key={`intro-${i}`} className="seval-detail-intro">{p}</p>
))}
{narrative.sections.map(section => (
<div key={section.title} className="seval-detail-block">
<h5 className="seval-detail-block-title">{section.title}</h5>
{section.paragraphs.map((p, i) => (
<p key={`${section.title}-p-${i}`} className="seval-detail-para">{p}</p>
))}
{section.bullets && section.bullets.length > 0 && (
<ul className="seval-detail-list">
{section.bullets.map((line, i) => (
<li key={`${section.title}-b-${i}`} className="seval-detail-list-item">{line}</li>
))}
</ul>
)}
</div>
))}
{narrative.footnotes.length > 0 && (
<footer className="seval-detail-footnotes">
{narrative.footnotes.map((note, i) => (
<p key={`fn-${i}`} className="seval-detail-footnote"> {note}</p>
))}
</footer>
)}
</div>
</section>
</div>
</div>
);
};
export default StrategyEvaluationStrategyDetail;
+15
View File
@@ -12,6 +12,21 @@
position: relative; 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 { .se-save-toast {
position: absolute; position: absolute;
top: 56px; top: 56px;
+249
View File
@@ -80,6 +80,69 @@
height: 100%; 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 { .seval-main-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -969,6 +1032,14 @@
overflow: hidden; overflow: hidden;
} }
.seval-settings-top {
flex: 1 1 52%;
min-height: 120px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.seval-settings-head { .seval-settings-head {
flex-shrink: 0; flex-shrink: 0;
padding: 4px 2px 8px; padding: 4px 2px 8px;
@@ -1108,6 +1179,184 @@
cursor: not-allowed; 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 { .seval-analyze-btn {
flex-shrink: 0; flex-shrink: 0;