전략편집기 팝업 기능 적용
This commit is contained in:
@@ -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,26 +331,41 @@ 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="btd-exec-tabs" role="tablist">
|
<div className="seval-left-tabs-row">
|
||||||
<button
|
<div className="btd-exec-tabs" role="tablist">
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={leftTab === 'strategy'}
|
role="tab"
|
||||||
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
aria-selected={leftTab === 'strategy'}
|
||||||
onClick={() => setLeftTab('strategy')}
|
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
||||||
>
|
onClick={() => setLeftTab('strategy')}
|
||||||
전략
|
>
|
||||||
</button>
|
전략
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={leftTab === 'settings'}
|
role="tab"
|
||||||
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
aria-selected={leftTab === 'settings'}
|
||||||
onClick={() => setLeftTab('settings')}
|
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
||||||
disabled={!selectedStrategy}
|
onClick={() => setLeftTab('settings')}
|
||||||
>
|
disabled={!selectedStrategy}
|
||||||
전략설정
|
>
|
||||||
</button>
|
전략설정
|
||||||
|
</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>
|
</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,75 +125,83 @@ const StrategyEvaluationSettingsTab: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="seval-settings-tab">
|
<div className="seval-settings-tab">
|
||||||
<div className="seval-settings-head">
|
<div className="seval-settings-top">
|
||||||
<div className="seval-settings-head-row">
|
<div className="seval-settings-head">
|
||||||
<p className="seval-settings-strategy">{strategyName}</p>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="seval-settings-apply"
|
className="seval-settings-reset"
|
||||||
disabled={!dirty || applying}
|
disabled={(!appliedParams && !dirty) || applying}
|
||||||
onClick={handleApply}
|
onClick={onReset}
|
||||||
title="변경된 지표 값을 차트·일치율에 반영"
|
|
||||||
>
|
>
|
||||||
{applying ? '적용 중…' : '적용'}
|
초기화
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="seval-settings-hint">지표 값을 변경한 뒤 적용을 누르면 차트·일치율이 갱신됩니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="seval-settings-list">
|
<StrategyEvaluationStrategyDetail
|
||||||
{indicatorTypes.map(type => {
|
strategy={strategy}
|
||||||
const def = getIndicatorDef(type);
|
getParams={getParams}
|
||||||
const params = draft[type] ?? {};
|
appliedParams={appliedParams}
|
||||||
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>
|
|
||||||
</div>
|
</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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user