전략편집기 팝업 기능 적용

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 {
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<StrategyDto[]>(() => loadStratsLocal());
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 = () => {
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 (
<div className={`se-page se-page--${theme}`}>
<div className={`se-page se-page--${theme}${variant === 'modal' ? ' se-page--modal' : ''}`}>
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
{saveToast && (
<div className="se-save-toast"> DB에 .</div>
@@ -1711,6 +1740,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
<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
type="button"
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 StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import { getKoreanName } from '../utils/marketNameCache';
@@ -75,6 +76,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [paramsRevision, setParamsRevision] = useState(0);
const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
const [editorModalOpen, setEditorModalOpen] = useState(false);
const evalSessionRef = useRef(0);
const paramsRevisionRef = useRef(paramsRevision);
@@ -169,6 +171,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
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(() => {
if (!selectedStrategyId) {
setSelectedStrategy(null);
@@ -308,26 +331,41 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
>
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
<div className="btd-exec-list seval-left-panel">
<div className="btd-exec-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={leftTab === 'strategy'}
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('strategy')}
>
</button>
<button
type="button"
role="tab"
aria-selected={leftTab === 'settings'}
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('settings')}
disabled={!selectedStrategy}
>
</button>
<div className="seval-left-tabs-row">
<div className="btd-exec-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={leftTab === 'strategy'}
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('strategy')}
>
</button>
<button
type="button"
role="tab"
aria-selected={leftTab === 'settings'}
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('settings')}
disabled={!selectedStrategy}
>
</button>
</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' ? (
@@ -487,6 +525,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</footer>
</aside>
</div>
{editorModalOpen && (
<StrategyEditorModal
theme={theme}
initialStrategyId={selectedStrategyId}
onClose={handleCloseStrategyEditor}
onStrategiesChanged={() => { void refreshStrategies(); }}
/>
)}
</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,
} 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<Props> = ({
return (
<div className="seval-settings-tab">
<div className="seval-settings-head">
<div className="seval-settings-head-row">
<p className="seval-settings-strategy">{strategyName}</p>
<div className="seval-settings-top">
<div className="seval-settings-head">
<div className="seval-settings-head-row">
<p className="seval-settings-strategy">{strategyName}</p>
<button
type="button"
className="seval-settings-apply"
disabled={!dirty || applying}
onClick={handleApply}
title="변경된 지표 값을 차트·일치율에 반영"
>
{applying ? '적용 중…' : '적용'}
</button>
</div>
<p className="seval-settings-hint"> · </p>
</div>
<div className="seval-settings-list">
{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 (
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
<button
type="button"
className="seval-settings-card-head"
onClick={() => setExpanded(isOpen ? null : type)}
>
<span className="seval-settings-card-title">{label}</span>
<span className="seval-settings-card-type">{type}</span>
</button>
{isOpen && (
<div className="seval-settings-card-body">
{paramKeys.map(key => (
<GlobalParamRow
key={key}
indicatorType={type}
paramKey={key}
value={params[key] ?? getParams(type)[key]}
onChange={(k, raw) => patchParam(type, k, raw)}
/>
))}
<button
type="button"
className="seval-settings-reset-type"
onClick={() => resetType(type)}
>
</button>
</div>
)}
</div>
);
})}
</div>
<div className="seval-settings-actions">
<button
type="button"
className="seval-settings-apply"
disabled={!dirty || applying}
onClick={handleApply}
title="변경된 지표 값을 차트·일치율에 반영"
className="seval-settings-reset"
disabled={(!appliedParams && !dirty) || applying}
onClick={onReset}
>
{applying ? '적용 중…' : '적용'}
</button>
</div>
<p className="seval-settings-hint"> · </p>
</div>
<div className="seval-settings-list">
{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 (
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
<button
type="button"
className="seval-settings-card-head"
onClick={() => setExpanded(isOpen ? null : type)}
>
<span className="seval-settings-card-title">{label}</span>
<span className="seval-settings-card-type">{type}</span>
</button>
{isOpen && (
<div className="seval-settings-card-body">
{paramKeys.map(key => (
<GlobalParamRow
key={key}
indicatorType={type}
paramKey={key}
value={params[key] ?? getParams(type)[key]}
onChange={(k, raw) => patchParam(type, k, raw)}
/>
))}
<button
type="button"
className="seval-settings-reset-type"
onClick={() => resetType(type)}
>
</button>
</div>
)}
</div>
);
})}
</div>
<div className="seval-settings-actions">
<button
type="button"
className="seval-settings-reset"
disabled={(!appliedParams && !dirty) || applying}
onClick={onReset}
>
</button>
</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;