전략평가 화면에서 전략조건 수정
This commit is contained in:
@@ -23,7 +23,8 @@ import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicat
|
|||||||
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
|
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
|
||||||
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
|
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
|
||||||
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
||||||
import StrategyEvaluationConditionPanel from './strategyEvaluation/StrategyEvaluationConditionPanel';
|
import StrategyEvaluationStrategyPanel from './strategyEvaluation/StrategyEvaluationStrategyPanel';
|
||||||
|
import StrategyEvaluationIndicatorPalettePanel from './strategyEvaluation/StrategyEvaluationIndicatorPalettePanel';
|
||||||
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
||||||
import StrategyEvaluationMarketTab from './strategyEvaluation/StrategyEvaluationMarketTab';
|
import StrategyEvaluationMarketTab from './strategyEvaluation/StrategyEvaluationMarketTab';
|
||||||
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
|
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
|
||||||
@@ -51,6 +52,9 @@ import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
|||||||
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
||||||
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
||||||
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
||||||
|
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
|
||||||
|
import { needsPointerPaletteDrag } from '../utils/paletteDragSession';
|
||||||
|
import { useStrategyEvaluationEditor } from '../hooks/useStrategyEvaluationEditor';
|
||||||
import {
|
import {
|
||||||
buildStrategyEvaluationAiVerifyContext,
|
buildStrategyEvaluationAiVerifyContext,
|
||||||
requestStrategyEvaluationAiVerify,
|
requestStrategyEvaluationAiVerify,
|
||||||
@@ -66,7 +70,7 @@ const LEFT_DEFAULT = 380;
|
|||||||
const RIGHT_DEFAULT = 440;
|
const RIGHT_DEFAULT = 440;
|
||||||
|
|
||||||
type LeftTab = 'strategy' | 'market' | 'settings';
|
type LeftTab = 'strategy' | 'market' | 'settings';
|
||||||
type RightTab = 'signal' | 'chart' | 'ai';
|
type RightTab = 'signal' | 'indicators' | 'chart' | 'ai';
|
||||||
|
|
||||||
function readMinWidth(key: string, fallback: number): number {
|
function readMinWidth(key: string, fallback: number): number {
|
||||||
return Math.max(readStoredSize(key, fallback), fallback);
|
return Math.max(readStoredSize(key, fallback), fallback);
|
||||||
@@ -139,6 +143,34 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
[baseGetParams, appliedIndicatorParams],
|
[baseGetParams, appliedIndicatorParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleStrategyEditorSaved = useCallback((saved: StrategyDto, meta?: { created?: boolean }) => {
|
||||||
|
const hydrated = hydrateStrategyDto(saved);
|
||||||
|
setSelectedStrategy(hydrated);
|
||||||
|
setStrategies(prev => {
|
||||||
|
const exists = prev.some(s => s.id === hydrated.id);
|
||||||
|
if (exists) return prev.map(s => (s.id === hydrated.id ? hydrated : s));
|
||||||
|
return [...prev, hydrated];
|
||||||
|
});
|
||||||
|
if (meta?.created && hydrated.id != null) {
|
||||||
|
setSelectedStrategyId(hydrated.id);
|
||||||
|
}
|
||||||
|
evalSessionRef.current += 1;
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setEvalError(null);
|
||||||
|
setEvalLoading(true);
|
||||||
|
reportCacheKeyRef.current = null;
|
||||||
|
setReportModel(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const strategyEditor = useStrategyEvaluationEditor({
|
||||||
|
strategy: selectedStrategy,
|
||||||
|
strategies,
|
||||||
|
getParams: baseGetParams,
|
||||||
|
getVisualConfig,
|
||||||
|
settingsRevision,
|
||||||
|
onStrategySaved: handleStrategyEditorSaved,
|
||||||
|
});
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||||
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
||||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||||
@@ -784,10 +816,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
refreshMarketTickers={marketFeed.refreshAllTickers}
|
refreshMarketTickers={marketFeed.refreshAllTickers}
|
||||||
onMarketSearchOpenChange={setMarketSearchOpen}
|
onMarketSearchOpenChange={setMarketSearchOpen}
|
||||||
/>
|
/>
|
||||||
<StrategyEvaluationConditionPanel
|
<StrategyEvaluationStrategyPanel
|
||||||
strategy={selectedStrategy}
|
strategy={selectedStrategy}
|
||||||
getParams={baseGetParams}
|
editor={strategyEditor}
|
||||||
appliedParams={appliedIndicatorParams}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -800,7 +831,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
side="right"
|
side="right"
|
||||||
open={rightOpen}
|
open={rightOpen}
|
||||||
onToggle={toggleRightPanel}
|
onToggle={toggleRightPanel}
|
||||||
label={rightTab === 'chart' ? '차트설정 패널' : '시그널 패널'}
|
label={
|
||||||
|
rightTab === 'chart'
|
||||||
|
? '차트설정 패널'
|
||||||
|
: rightTab === 'indicators'
|
||||||
|
? '전략지표 패널'
|
||||||
|
: '시그널 패널'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{rightOpen && (
|
{rightOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -824,6 +861,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
>
|
>
|
||||||
시그널
|
시그널
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id="seval-right-tab-indicators"
|
||||||
|
aria-selected={rightTab === 'indicators'}
|
||||||
|
aria-controls="seval-right-panel-indicators"
|
||||||
|
className={`btd-exec-tab seval-right-tab${rightTab === 'indicators' ? ' btd-exec-tab--on' : ''}`}
|
||||||
|
onClick={() => setRightTab('indicators')}
|
||||||
|
>
|
||||||
|
전략지표
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -905,6 +953,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rightTab === 'indicators' && (
|
||||||
|
<div
|
||||||
|
id="seval-right-panel-indicators"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="seval-right-tab-indicators"
|
||||||
|
className="seval-right-tab-panel seval-right-tab-panel--indicators"
|
||||||
|
>
|
||||||
|
<StrategyEvaluationIndicatorPalettePanel editor={strategyEditor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{rightTab === 'chart' && (
|
{rightTab === 'chart' && (
|
||||||
<div
|
<div
|
||||||
id="seval-right-panel-chart"
|
id="seval-right-panel-chart"
|
||||||
@@ -947,6 +1006,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
|
||||||
|
|
||||||
{editorModalOpen && (
|
{editorModalOpen && (
|
||||||
<StrategyEditorModal
|
<StrategyEditorModal
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 전략 평가 — 우측 패널 전략지표 (전략편집기 지표 탭과 동일)
|
||||||
|
*/
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import PaletteChip from '../strategyEditor/PaletteChip';
|
||||||
|
import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab';
|
||||||
|
import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab';
|
||||||
|
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
loadPaletteItems,
|
||||||
|
type PaletteItem,
|
||||||
|
} from '../../utils/strategyPaletteStorage';
|
||||||
|
import { loadSidewaysPaletteItems } from '../../utils/sidewaysFilterPaletteStorage';
|
||||||
|
import type { DefType } from '../../utils/strategyEditorShared';
|
||||||
|
import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluationEditor';
|
||||||
|
|
||||||
|
type EditorApi = Pick<
|
||||||
|
ReturnType<typeof useStrategyEvaluationEditor>,
|
||||||
|
'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: EditorApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS = [
|
||||||
|
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||||||
|
{ type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' },
|
||||||
|
{ type: 'operator' as const, value: 'NOT', label: 'NOT', color: 'logic-not' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MA_BAND_ITEMS = [
|
||||||
|
{ type: 'indicator' as const, value: 'MA', label: 'MA', desc: '이동평균', color: 'band' },
|
||||||
|
{ type: 'indicator' as const, value: 'EMA', label: 'EMA', desc: '지수이동평균', color: 'band' },
|
||||||
|
{ type: 'indicator' as const, value: 'BOLLINGER', label: 'Bollinger', desc: '볼린저밴드', color: 'band' },
|
||||||
|
{ type: 'indicator' as const, value: 'DONCHIAN', label: 'Donchian', desc: '돈치안 채널', color: 'band' },
|
||||||
|
{ type: 'indicator' as const, value: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function paletteKey(type: string, id: string) {
|
||||||
|
return `${type}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => {
|
||||||
|
const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor;
|
||||||
|
|
||||||
|
const [paletteSearch, setPaletteSearch] = useState('');
|
||||||
|
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
|
||||||
|
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||||
|
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||||
|
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||||||
|
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const q = paletteSearch.trim().toLowerCase();
|
||||||
|
const match = (label: string, desc?: string) =>
|
||||||
|
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||||||
|
|
||||||
|
const isPaletteSelected = (type: string, id: string) =>
|
||||||
|
selectedPaletteKey === paletteKey(type, id);
|
||||||
|
|
||||||
|
const selectPalette = (type: string, id: string) => {
|
||||||
|
setSelectedPaletteKey(paletteKey(type, id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaBand = useMemo(
|
||||||
|
() => MA_BAND_ITEMS.filter(i => match(i.label, i.desc)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[q],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="seval-indicator-palette se-palette-panel">
|
||||||
|
<input
|
||||||
|
className="se-palette-search"
|
||||||
|
placeholder="지표 검색 (RSI, MACD…)"
|
||||||
|
value={paletteSearch}
|
||||||
|
onChange={e => setPaletteSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="se-palette-section se-palette-section--logic">
|
||||||
|
<h3>시작 · 논리</h3>
|
||||||
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
|
<PaletteChip
|
||||||
|
type="start"
|
||||||
|
value="START"
|
||||||
|
label="START"
|
||||||
|
desc="시간봉 시작점"
|
||||||
|
color="logic-start"
|
||||||
|
selected={selectedPaletteKey === 'start:START'}
|
||||||
|
onSelect={() => setSelectedPaletteKey('start:START')}
|
||||||
|
onAdd={() => applyPalette('start', 'START')}
|
||||||
|
/>
|
||||||
|
{OPERATORS.map(op => (
|
||||||
|
<PaletteChip
|
||||||
|
key={op.value}
|
||||||
|
{...op}
|
||||||
|
selected={isPaletteSelected(op.type, op.value)}
|
||||||
|
onSelect={() => selectPalette(op.type, op.value)}
|
||||||
|
onAdd={() => applyPalette(op.type, op.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filteredMaBand.length > 0 && (
|
||||||
|
<div className="se-palette-section se-palette-section--band">
|
||||||
|
<h3>밴드 · 추세</h3>
|
||||||
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
|
{filteredMaBand.map(item => (
|
||||||
|
<PaletteChip
|
||||||
|
key={item.value}
|
||||||
|
{...item}
|
||||||
|
period={getIndicatorPeriodLabel(item.value, def as DefType)}
|
||||||
|
selected={isPaletteSelected(item.type, item.value)}
|
||||||
|
onSelect={() => selectPalette(item.type, item.value)}
|
||||||
|
onAdd={() => applyPalette(item.type, item.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="se-palette-subtabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIndicatorSubTab('auxiliary');
|
||||||
|
setSelectedPaletteKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
보조지표
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIndicatorSubTab('composite');
|
||||||
|
setSelectedPaletteKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
복합지표
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIndicatorSubTab('range');
|
||||||
|
setSelectedPaletteKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
횡보필터
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{indicatorSubTab === 'auxiliary' ? (
|
||||||
|
<IndicatorPaletteTab
|
||||||
|
kind="auxiliary"
|
||||||
|
items={auxiliaryPalette}
|
||||||
|
onItemsChange={setAuxiliaryPalette}
|
||||||
|
def={def as DefType}
|
||||||
|
searchQuery={paletteSearch}
|
||||||
|
selectedItemId={
|
||||||
|
selectedPaletteKey?.startsWith('auxiliary:')
|
||||||
|
? selectedPaletteKey.slice('auxiliary:'.length)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('auxiliary', id) : null)}
|
||||||
|
onAddToCanvas={item => {
|
||||||
|
selectPalette('auxiliary', item.id);
|
||||||
|
applyPaletteItem(item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : indicatorSubTab === 'composite' ? (
|
||||||
|
<IndicatorPaletteTab
|
||||||
|
kind="composite"
|
||||||
|
items={compositePalette}
|
||||||
|
onItemsChange={setCompositePalette}
|
||||||
|
def={def as DefType}
|
||||||
|
searchQuery={paletteSearch}
|
||||||
|
selectedItemId={
|
||||||
|
selectedPaletteKey?.startsWith('composite:')
|
||||||
|
? selectedPaletteKey.slice('composite:'.length)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('composite', id) : null)}
|
||||||
|
onAddToCanvas={item => {
|
||||||
|
selectPalette('composite', item.id);
|
||||||
|
applyPaletteItem(item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SidewaysFilterPaletteTab
|
||||||
|
def={def as DefType}
|
||||||
|
items={sidewaysPalette}
|
||||||
|
onItemsChange={setSidewaysPalette}
|
||||||
|
searchQuery={paletteSearch}
|
||||||
|
selectedItemId={
|
||||||
|
selectedPaletteKey?.startsWith('range:')
|
||||||
|
? selectedPaletteKey.slice('range:'.length)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
|
||||||
|
onAddToCanvas={item => {
|
||||||
|
selectPalette('range', item.id);
|
||||||
|
applySidewaysFilter(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StrategyEvaluationIndicatorPalettePanel;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 전략 평가 — 중앙 하단 목록 방식 전략 설정
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
|
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||||
|
import StrategyListEditor from '../strategyEditor/StrategyListEditor';
|
||||||
|
import StartCombineOpControl from '../strategyEditor/StartCombineOpControl';
|
||||||
|
import DraggableModalFrame from '../DraggableModalFrame';
|
||||||
|
import { hasMultipleStartSections, normalizeStartCombineOp } from '../../utils/strategyConditionSerde';
|
||||||
|
import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluationEditor';
|
||||||
|
|
||||||
|
type EditorApi = ReturnType<typeof useStrategyEvaluationEditor>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
strategy: StrategyDto | null;
|
||||||
|
editor: EditorApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StrategyEvaluationStrategyPanel: React.FC<Props> = ({ strategy, editor }) => {
|
||||||
|
const {
|
||||||
|
def,
|
||||||
|
signalTab,
|
||||||
|
switchSignalTab,
|
||||||
|
currentEditorState,
|
||||||
|
handleEditorStateChange,
|
||||||
|
handleStartCombineOpChange,
|
||||||
|
handleAddStartSection,
|
||||||
|
currentOrphans,
|
||||||
|
handleOrphansChange,
|
||||||
|
saving,
|
||||||
|
saveNow,
|
||||||
|
openSaveDialog,
|
||||||
|
closeSaveDialog,
|
||||||
|
confirmSaveDialog,
|
||||||
|
saveDialogOpen,
|
||||||
|
saveDialogMode,
|
||||||
|
saveDraftName,
|
||||||
|
setSaveDraftName,
|
||||||
|
saveDraftDesc,
|
||||||
|
setSaveDraftDesc,
|
||||||
|
saveNameError,
|
||||||
|
saveDuplicateError,
|
||||||
|
saveToast,
|
||||||
|
clearSaveFieldErrors,
|
||||||
|
} = editor;
|
||||||
|
|
||||||
|
if (!strategy) {
|
||||||
|
return (
|
||||||
|
<div className="seval-strategy-panel seval-strategy-panel--empty">
|
||||||
|
<p className="seval-strategy-panel-empty">전략을 선택하면 목록 방식으로 조건을 편집할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="seval-strategy-panel">
|
||||||
|
<header className="seval-strategy-panel-head">
|
||||||
|
<div className="seval-strategy-panel-head-main">
|
||||||
|
<div className="se-signal-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-signal-tab${signalTab === 'buy' ? ' se-signal-tab--buy-on' : ''}`}
|
||||||
|
onClick={() => switchSignalTab('buy')}
|
||||||
|
>
|
||||||
|
매수 조건 (Entry)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`se-signal-tab${signalTab === 'sell' ? ' se-signal-tab--sell-on' : ''}`}
|
||||||
|
onClick={() => switchSignalTab('sell')}
|
||||||
|
>
|
||||||
|
매도 조건 (Exit)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="seval-strategy-panel-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-btn se-btn--gold se-btn--sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => { void saveNow(); }}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중…' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-btn se-btn--ghost se-btn--sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => openSaveDialog('saveAs')}
|
||||||
|
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
|
||||||
|
>
|
||||||
|
새로저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="seval-strategy-panel-meta">
|
||||||
|
{strategyName && <span className="se-editing-name">{strategyName}</span>}
|
||||||
|
{saving && <span className="seval-strategy-panel-saving">저장 중…</span>}
|
||||||
|
</div>
|
||||||
|
{hasMultipleStartSections(currentEditorState) && (
|
||||||
|
<StartCombineOpControl
|
||||||
|
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
|
||||||
|
onChange={handleStartCombineOpChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="seval-strategy-panel-body se-center-work se-center-work--list">
|
||||||
|
<StrategyListEditor
|
||||||
|
editorState={currentEditorState}
|
||||||
|
signalTab={signalTab}
|
||||||
|
def={def}
|
||||||
|
onEditorStateChange={handleEditorStateChange}
|
||||||
|
onAddStart={handleAddStartSection}
|
||||||
|
orphans={currentOrphans}
|
||||||
|
onOrphansChange={handleOrphansChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveToast && (
|
||||||
|
<div className="seval-strategy-save-toast" role="status">{saveToast}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveDialogOpen && (
|
||||||
|
<DraggableModalFrame
|
||||||
|
onClose={closeSaveDialog}
|
||||||
|
title={saveDialogMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
|
||||||
|
>
|
||||||
|
<div className="se-modal-body">
|
||||||
|
{saveDialogMode === 'saveAs' && (
|
||||||
|
<p className="se-template-save-hint">
|
||||||
|
현재 편집 중인 조건을 <strong>새 전략</strong>으로 저장합니다.
|
||||||
|
기존 전략「{strategyName}」은 변경되지 않습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="se-field-lbl">전략명 *</label>
|
||||||
|
<input
|
||||||
|
className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`}
|
||||||
|
value={saveDraftName}
|
||||||
|
onChange={e => {
|
||||||
|
setSaveDraftName(e.target.value);
|
||||||
|
clearSaveFieldErrors();
|
||||||
|
}}
|
||||||
|
placeholder="예: Golden_RSI_V1"
|
||||||
|
/>
|
||||||
|
{saveNameError && (
|
||||||
|
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
|
||||||
|
)}
|
||||||
|
{saveDuplicateError && (
|
||||||
|
<p className="se-field-error" role="alert">같은 이름의 전략이 이미 있습니다</p>
|
||||||
|
)}
|
||||||
|
<label className="se-field-lbl">설명</label>
|
||||||
|
<textarea
|
||||||
|
className="se-field-ta"
|
||||||
|
value={saveDraftDesc}
|
||||||
|
onChange={e => setSaveDraftDesc(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="se-modal-actions">
|
||||||
|
<button type="button" className="se-btn se-btn--ghost" onClick={closeSaveDialog}>취소</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-btn se-btn--gold"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => { void confirmSaveDialog(); }}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중…' : (saveDialogMode === 'saveAs' ? '새로저장' : '저장')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DraggableModalFrame>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StrategyEvaluationStrategyPanel;
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* 전략 평가 — 목록 방식 전략 편집 (매수/매도 DSL + DB 자동 저장)
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { LogicNode } from '../utils/strategyTypes';
|
||||||
|
import type { StrategyDto } from '../utils/backendApi';
|
||||||
|
import { asLogicNode, hydrateStrategyDto } from '../utils/strategyHydrate';
|
||||||
|
import {
|
||||||
|
type EditorConditionState,
|
||||||
|
decodeConditionForEditor,
|
||||||
|
emptyEditorConditionState,
|
||||||
|
addExtraStartSection,
|
||||||
|
updateBranchRoot,
|
||||||
|
updateStartCombineOp,
|
||||||
|
alignBuySellStartCandleTypesForSave,
|
||||||
|
normalizeStartCombineOp,
|
||||||
|
encodeConditionForSave,
|
||||||
|
} from '../utils/strategyConditionSerde';
|
||||||
|
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
|
||||||
|
import { saveStrategy } from '../utils/backendApi';
|
||||||
|
import { normalizeLogicRootForPersistence } from '../utils/strategyConditionNormalize';
|
||||||
|
import {
|
||||||
|
buildStrategyFlowLayoutStore,
|
||||||
|
emptyStrategyFlowLayout,
|
||||||
|
normalizeStrategyFlowLayout,
|
||||||
|
type StrategyFlowLayoutStore,
|
||||||
|
} from '../utils/strategyEditorLayoutStorage';
|
||||||
|
import {
|
||||||
|
buildStrategyEditorDefFromSettings,
|
||||||
|
makeNode,
|
||||||
|
mergeAtRoot,
|
||||||
|
} from '../utils/strategyEditorShared';
|
||||||
|
import { START_NODE_ID } from '../utils/strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
buildStochOverboughtPairTree,
|
||||||
|
isStochOverboughtPairPaletteValue,
|
||||||
|
} from '../utils/stochOverboughtPair';
|
||||||
|
import {
|
||||||
|
buildPriceExtremeBreakoutTree,
|
||||||
|
isPriceExtremeBreakoutPairPaletteValue,
|
||||||
|
} from '../utils/priceExtremeBreakoutPair';
|
||||||
|
import {
|
||||||
|
buildInflection33Tree,
|
||||||
|
isInflection33PaletteValue,
|
||||||
|
} from '../utils/inflection33Strategy';
|
||||||
|
import {
|
||||||
|
buildStableStrategyTree,
|
||||||
|
isStableStrategyPaletteValue,
|
||||||
|
} from '../utils/stableStrategyPairs';
|
||||||
|
import {
|
||||||
|
buildSidewaysFilterNode,
|
||||||
|
loadSidewaysPaletteItems,
|
||||||
|
} from '../utils/sidewaysFilterPaletteStorage';
|
||||||
|
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||||
|
import type { IndicatorVisualConfig } from './useIndicatorSettings';
|
||||||
|
import type { PaletteItem } from '../utils/strategyPaletteStorage';
|
||||||
|
|
||||||
|
const AUTOSAVE_MS = 400;
|
||||||
|
|
||||||
|
type SaveDialogMode = 'update' | 'saveAs';
|
||||||
|
|
||||||
|
function suggestSaveAsName(baseName: string, existing: StrategyDto[]): string {
|
||||||
|
const trimmed = baseName.trim() || '새 전략';
|
||||||
|
const names = new Set(existing.map(s => s.name.trim()));
|
||||||
|
let candidate = `${trimmed} (복사)`;
|
||||||
|
let n = 2;
|
||||||
|
while (names.has(candidate)) {
|
||||||
|
candidate = `${trimmed} (복사 ${n})`;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicateStrategyName(
|
||||||
|
name: string,
|
||||||
|
strategies: StrategyDto[],
|
||||||
|
excludeId?: number | null,
|
||||||
|
): boolean {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStrategyEvaluationEditorOptions {
|
||||||
|
strategy: StrategyDto | null;
|
||||||
|
strategies: StrategyDto[];
|
||||||
|
getParams: (
|
||||||
|
type: string,
|
||||||
|
defaults?: Record<string, number | string | boolean>,
|
||||||
|
) => Record<string, number | string | boolean>;
|
||||||
|
getVisualConfig: (
|
||||||
|
type: string,
|
||||||
|
defaultPlots?: PlotDef[],
|
||||||
|
defaultHlines?: HLineDef[],
|
||||||
|
) => IndicatorVisualConfig;
|
||||||
|
settingsRevision?: number;
|
||||||
|
onStrategySaved: (saved: StrategyDto, meta?: { created?: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStrategyEvaluationEditor({
|
||||||
|
strategy,
|
||||||
|
strategies,
|
||||||
|
getParams,
|
||||||
|
getVisualConfig,
|
||||||
|
settingsRevision = 0,
|
||||||
|
onStrategySaved,
|
||||||
|
}: UseStrategyEvaluationEditorOptions) {
|
||||||
|
const def = useMemo(
|
||||||
|
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||||
|
[getParams, getVisualConfig, settingsRevision],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||||
|
const [buyEditorState, setBuyEditorState] = useState<EditorConditionState>(emptyEditorConditionState);
|
||||||
|
const [sellEditorState, setSellEditorState] = useState<EditorConditionState>(emptyEditorConditionState);
|
||||||
|
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||||||
|
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||||||
|
const [flowLayout, setFlowLayout] = useState<StrategyFlowLayoutStore>(emptyStrategyFlowLayout());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
|
const [saveDialogMode, setSaveDialogMode] = useState<SaveDialogMode>('update');
|
||||||
|
const [saveDraftName, setSaveDraftName] = useState('');
|
||||||
|
const [saveDraftDesc, setSaveDraftDesc] = useState('');
|
||||||
|
const [saveNameError, setSaveNameError] = useState(false);
|
||||||
|
const [saveDuplicateError, setSaveDuplicateError] = useState(false);
|
||||||
|
const [saveToast, setSaveToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const strategiesRef = useRef(strategies);
|
||||||
|
strategiesRef.current = strategies;
|
||||||
|
|
||||||
|
const buyEditorStateRef = useRef(buyEditorState);
|
||||||
|
const sellEditorStateRef = useRef(sellEditorState);
|
||||||
|
const buyOrphansRef = useRef(buyOrphans);
|
||||||
|
const sellOrphansRef = useRef(sellOrphans);
|
||||||
|
const flowLayoutRef = useRef(flowLayout);
|
||||||
|
const strategyRef = useRef(strategy);
|
||||||
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
|
const saveGenRef = useRef(0);
|
||||||
|
|
||||||
|
buyEditorStateRef.current = buyEditorState;
|
||||||
|
sellEditorStateRef.current = sellEditorState;
|
||||||
|
buyOrphansRef.current = buyOrphans;
|
||||||
|
sellOrphansRef.current = sellOrphans;
|
||||||
|
flowLayoutRef.current = flowLayout;
|
||||||
|
strategyRef.current = strategy;
|
||||||
|
|
||||||
|
const currentEditorState = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||||
|
const currentEditorStateRef = useRef(currentEditorState);
|
||||||
|
currentEditorStateRef.current = currentEditorState;
|
||||||
|
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
||||||
|
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
||||||
|
|
||||||
|
const loadedStrategyIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!strategy?.id) {
|
||||||
|
loadedStrategyIdRef.current = null;
|
||||||
|
setBuyEditorState(emptyEditorConditionState());
|
||||||
|
setSellEditorState(emptyEditorConditionState());
|
||||||
|
setBuyOrphans([]);
|
||||||
|
setSellOrphans([]);
|
||||||
|
setFlowLayout(emptyStrategyFlowLayout());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadedStrategyIdRef.current === strategy.id) return;
|
||||||
|
loadedStrategyIdRef.current = strategy.id;
|
||||||
|
|
||||||
|
const buy = asLogicNode(strategy.buyCondition);
|
||||||
|
const sell = asLogicNode(strategy.sellCondition);
|
||||||
|
setBuyEditorState(decodeConditionForEditor(buy));
|
||||||
|
setSellEditorState(decodeConditionForEditor(sell));
|
||||||
|
const fl = normalizeStrategyFlowLayout(strategy.flowLayout as StrategyFlowLayoutStore | null)
|
||||||
|
?? emptyStrategyFlowLayout();
|
||||||
|
setFlowLayout(fl);
|
||||||
|
setBuyOrphans(fl.buy.orphans ?? []);
|
||||||
|
setSellOrphans(fl.sell.orphans ?? []);
|
||||||
|
setSignalTab('buy');
|
||||||
|
}, [strategy?.id, strategy]);
|
||||||
|
|
||||||
|
const buildMergedFlow = useCallback((
|
||||||
|
buy: EditorConditionState,
|
||||||
|
sell: EditorConditionState,
|
||||||
|
buyOrphans: LogicNode[],
|
||||||
|
sellOrphans: LogicNode[],
|
||||||
|
fl: StrategyFlowLayoutStore,
|
||||||
|
) => {
|
||||||
|
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||||
|
return {
|
||||||
|
aligned,
|
||||||
|
mergedFlow: buildStrategyFlowLayoutStore(
|
||||||
|
{
|
||||||
|
...fl.buy,
|
||||||
|
startMeta: aligned.buy.startMeta,
|
||||||
|
extraStartIds: aligned.buy.extraStartIds,
|
||||||
|
extraRoots: aligned.buy.extraRoots,
|
||||||
|
startCombineOp: normalizeStartCombineOp(aligned.buy.startCombineOp),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...fl.sell,
|
||||||
|
startMeta: aligned.sell.startMeta,
|
||||||
|
extraStartIds: aligned.sell.extraStartIds,
|
||||||
|
extraRoots: aligned.sell.extraRoots,
|
||||||
|
startCombineOp: normalizeStartCombineOp(aligned.sell.startCombineOp),
|
||||||
|
},
|
||||||
|
buyOrphans,
|
||||||
|
sellOrphans,
|
||||||
|
),
|
||||||
|
encodedBuy: encodeConditionForSave(aligned.buy),
|
||||||
|
encodedSell: encodeConditionForSave(aligned.sell),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelScheduledPersist = useCallback(() => {
|
||||||
|
if (saveTimerRef.current != null) {
|
||||||
|
window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSaveToast = useCallback((message: string) => {
|
||||||
|
setSaveToast(message);
|
||||||
|
window.setTimeout(() => setSaveToast(null), 2500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPersist = useCallback(async (options?: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
asNew?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
|
}): Promise<StrategyDto | null> => {
|
||||||
|
const strat = strategyRef.current;
|
||||||
|
const buy = buyEditorStateRef.current;
|
||||||
|
const sell = sellEditorStateRef.current;
|
||||||
|
const { aligned, mergedFlow, encodedBuy, encodedSell } = buildMergedFlow(
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
buyOrphansRef.current,
|
||||||
|
sellOrphansRef.current,
|
||||||
|
flowLayoutRef.current,
|
||||||
|
);
|
||||||
|
if (!encodedBuy && !encodedSell) {
|
||||||
|
if (!options?.silent) showSaveToast('조건을 최소 1개 추가하세요');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asNew = options?.asNew === true;
|
||||||
|
const name = (options?.name ?? strat?.name ?? '').trim();
|
||||||
|
if (!name) {
|
||||||
|
showSaveToast('전략 이름을 입력하세요');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const description = options?.description ?? strat?.description ?? '';
|
||||||
|
|
||||||
|
if (asNew) {
|
||||||
|
if (isDuplicateStrategyName(name, strategiesRef.current, null)) {
|
||||||
|
if (!options?.silent) {
|
||||||
|
setSaveDuplicateError(true);
|
||||||
|
showSaveToast('같은 이름의 전략이 이미 있습니다');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!strat?.id) return null;
|
||||||
|
if (isDuplicateStrategyName(name, strategiesRef.current, strat.id)) {
|
||||||
|
if (!options?.silent) {
|
||||||
|
setSaveDuplicateError(true);
|
||||||
|
showSaveToast('같은 이름의 전략이 이미 있습니다');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = ++saveGenRef.current;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let saved: StrategyDto;
|
||||||
|
if (asNew) {
|
||||||
|
saved = await saveStrategy({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
buyCondition: encodedBuy ? normalizeLogicRootForPersistence(encodedBuy) : encodedBuy,
|
||||||
|
sellCondition: encodedSell ? normalizeLogicRootForPersistence(encodedSell) : encodedSell,
|
||||||
|
flowLayout: mergedFlow,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
|
||||||
|
detail: { strategyId: saved.id, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout: mergedFlow },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
loadedStrategyIdRef.current = saved.id ?? null;
|
||||||
|
} else {
|
||||||
|
const strategyId = strat?.id;
|
||||||
|
if (strategyId == null) return null;
|
||||||
|
saved = await persistStrategyEditorState(
|
||||||
|
strategyId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
aligned.buy,
|
||||||
|
aligned.sell,
|
||||||
|
mergedFlow,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (gen !== saveGenRef.current) return null;
|
||||||
|
if (saved.id == null) {
|
||||||
|
if (!options?.silent) showSaveToast('저장 실패: 전략 ID 없음');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hydrated = hydrateStrategyDto(saved);
|
||||||
|
strategyRef.current = hydrated;
|
||||||
|
onStrategySaved(hydrated, asNew ? { created: true } : undefined);
|
||||||
|
return hydrated;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useStrategyEvaluationEditor] save failed', err);
|
||||||
|
if (!options?.silent) {
|
||||||
|
showSaveToast(err instanceof Error ? err.message : '저장 실패');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (gen === saveGenRef.current) setSaving(false);
|
||||||
|
}
|
||||||
|
}, [buildMergedFlow, onStrategySaved, showSaveToast]);
|
||||||
|
|
||||||
|
const saveNow = useCallback(async () => {
|
||||||
|
cancelScheduledPersist();
|
||||||
|
const saved = await flushPersist();
|
||||||
|
if (saved) showSaveToast('저장되었습니다');
|
||||||
|
return saved != null;
|
||||||
|
}, [cancelScheduledPersist, flushPersist, showSaveToast]);
|
||||||
|
|
||||||
|
const openSaveDialog = useCallback((mode: SaveDialogMode) => {
|
||||||
|
const strat = strategyRef.current;
|
||||||
|
if (!strat) return;
|
||||||
|
setSaveDialogMode(mode);
|
||||||
|
setSaveDraftName(
|
||||||
|
mode === 'saveAs'
|
||||||
|
? suggestSaveAsName(strat.name ?? '', strategiesRef.current)
|
||||||
|
: (strat.name ?? ''),
|
||||||
|
);
|
||||||
|
setSaveDraftDesc(strat.description ?? '');
|
||||||
|
setSaveNameError(false);
|
||||||
|
setSaveDuplicateError(false);
|
||||||
|
setSaveDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSaveDialog = useCallback(() => {
|
||||||
|
setSaveDialogOpen(false);
|
||||||
|
setSaveNameError(false);
|
||||||
|
setSaveDuplicateError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSaveFieldErrors = useCallback(() => {
|
||||||
|
setSaveNameError(false);
|
||||||
|
setSaveDuplicateError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmSaveDialog = useCallback(async () => {
|
||||||
|
const name = saveDraftName.trim();
|
||||||
|
if (!name) {
|
||||||
|
setSaveNameError(true);
|
||||||
|
showSaveToast('전략 이름을 입력하세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelScheduledPersist();
|
||||||
|
const saved = await flushPersist({
|
||||||
|
name,
|
||||||
|
description: saveDraftDesc,
|
||||||
|
asNew: saveDialogMode === 'saveAs',
|
||||||
|
});
|
||||||
|
if (!saved) return;
|
||||||
|
setSaveDialogOpen(false);
|
||||||
|
setSaveNameError(false);
|
||||||
|
setSaveDuplicateError(false);
|
||||||
|
showSaveToast(saveDialogMode === 'saveAs' ? '새 전략으로 저장되었습니다' : '저장되었습니다');
|
||||||
|
}, [
|
||||||
|
saveDraftName,
|
||||||
|
saveDraftDesc,
|
||||||
|
saveDialogMode,
|
||||||
|
cancelScheduledPersist,
|
||||||
|
flushPersist,
|
||||||
|
showSaveToast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const schedulePersist = useCallback(() => {
|
||||||
|
if (saveTimerRef.current != null) window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
void flushPersist({ silent: true });
|
||||||
|
}, AUTOSAVE_MS);
|
||||||
|
}, [flushPersist]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (saveTimerRef.current != null) window.clearTimeout(saveTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorStateChange = useCallback((
|
||||||
|
next: EditorConditionState | ((prev: EditorConditionState) => EditorConditionState),
|
||||||
|
) => {
|
||||||
|
const resolved = typeof next === 'function'
|
||||||
|
? next(currentEditorStateRef.current)
|
||||||
|
: next;
|
||||||
|
if (signalTab === 'buy') setBuyEditorState(resolved);
|
||||||
|
else setSellEditorState(resolved);
|
||||||
|
schedulePersist();
|
||||||
|
}, [signalTab, schedulePersist]);
|
||||||
|
|
||||||
|
const handleStartCombineOpChange = useCallback((op: import('../utils/strategyStartNodes').StartCombineOp) => {
|
||||||
|
handleEditorStateChange(prev => updateStartCombineOp(prev, op));
|
||||||
|
}, [handleEditorStateChange]);
|
||||||
|
|
||||||
|
const handleAddStartSection = useCallback(() => {
|
||||||
|
handleEditorStateChange(prev => addExtraStartSection(prev));
|
||||||
|
}, [handleEditorStateChange]);
|
||||||
|
|
||||||
|
const handleOrphansChange = useCallback((nodes: LogicNode[]) => {
|
||||||
|
setCurrentOrphans(nodes);
|
||||||
|
schedulePersist();
|
||||||
|
}, [setCurrentOrphans, schedulePersist]);
|
||||||
|
|
||||||
|
const applyPalette = useCallback((type: string, value: string, composite = false) => {
|
||||||
|
if (type === 'start') {
|
||||||
|
handleAddStartSection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newNode = makeNode(
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
signalTab,
|
||||||
|
def,
|
||||||
|
composite ? { composite: true } : undefined,
|
||||||
|
);
|
||||||
|
handleEditorStateChange(prev => {
|
||||||
|
const root = prev.root;
|
||||||
|
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||||||
|
if (type === 'operator') {
|
||||||
|
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, true));
|
||||||
|
}
|
||||||
|
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||||||
|
});
|
||||||
|
}, [signalTab, def, handleEditorStateChange, handleAddStartSection]);
|
||||||
|
|
||||||
|
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||||
|
const stochPair = isStochOverboughtPairPaletteValue(item.value);
|
||||||
|
const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value);
|
||||||
|
const inflection33 = isInflection33PaletteValue(item.value);
|
||||||
|
const stableStrategy = isStableStrategyPaletteValue(item.value);
|
||||||
|
const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !stableStrategy;
|
||||||
|
const newNode = stochPair
|
||||||
|
? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', def)
|
||||||
|
: priceExtremeBreakout
|
||||||
|
? buildPriceExtremeBreakoutTree(item.value, def)
|
||||||
|
: inflection33
|
||||||
|
? buildInflection33Tree(item.value, def)
|
||||||
|
: stableStrategy
|
||||||
|
? buildStableStrategyTree(item.value, def)
|
||||||
|
: makeNode('indicator', item.value, signalTab, def, composite ? { composite: true } : undefined);
|
||||||
|
if (!newNode) return;
|
||||||
|
handleEditorStateChange(prev => {
|
||||||
|
const root = prev.root;
|
||||||
|
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||||||
|
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||||||
|
});
|
||||||
|
}, [signalTab, def, handleEditorStateChange]);
|
||||||
|
|
||||||
|
const applySidewaysFilter = useCallback((filterId: string) => {
|
||||||
|
const newNode = buildSidewaysFilterNode(filterId, def, loadSidewaysPaletteItems());
|
||||||
|
if (!newNode) return;
|
||||||
|
handleEditorStateChange(prev => {
|
||||||
|
const root = prev.root;
|
||||||
|
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||||||
|
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||||||
|
});
|
||||||
|
}, [def, handleEditorStateChange]);
|
||||||
|
|
||||||
|
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||||
|
if (tab === signalTab) return;
|
||||||
|
void flushPersist();
|
||||||
|
setSignalTab(tab);
|
||||||
|
}, [signalTab, flushPersist]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
def,
|
||||||
|
signalTab,
|
||||||
|
switchSignalTab,
|
||||||
|
currentEditorState,
|
||||||
|
handleEditorStateChange,
|
||||||
|
handleStartCombineOpChange,
|
||||||
|
handleAddStartSection,
|
||||||
|
currentOrphans,
|
||||||
|
handleOrphansChange,
|
||||||
|
applyPalette,
|
||||||
|
applyPaletteItem,
|
||||||
|
applySidewaysFilter,
|
||||||
|
saving,
|
||||||
|
saveNow,
|
||||||
|
openSaveDialog,
|
||||||
|
closeSaveDialog,
|
||||||
|
confirmSaveDialog,
|
||||||
|
saveDialogOpen,
|
||||||
|
saveDialogMode,
|
||||||
|
saveDraftName,
|
||||||
|
setSaveDraftName,
|
||||||
|
saveDraftDesc,
|
||||||
|
setSaveDraftDesc,
|
||||||
|
saveNameError,
|
||||||
|
saveDuplicateError,
|
||||||
|
saveToast,
|
||||||
|
clearSaveFieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -992,7 +992,106 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 중앙 하단 — LOGIC EXPRESSION + 조건 설명 ── */
|
/* ── 중앙 하단 — 목록 방식 전략 설정 ── */
|
||||||
|
.seval-strategy-panel {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 min(40vh, 380px);
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: min(48vh, 440px);
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--se-terminal-border, rgba(255, 255, 255, 0.08));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--se-panel-card-bg, var(--se-terminal-bg, color-mix(in srgb, var(--bg) 92%, #000)));
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel--empty {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
border-bottom: 1px solid var(--se-border);
|
||||||
|
background: color-mix(in srgb, var(--se-bg-elevated, var(--bg2)) 88%, transparent);
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-head-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seval-strategy-save-toast {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 30;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--se-text);
|
||||||
|
background: color-mix(in srgb, var(--se-bg-elevated, #1a1f2e) 92%, var(--se-gold, #e6c200) 8%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 35%, var(--se-border));
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-saving {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 4px 6px;
|
||||||
|
}
|
||||||
|
.seval-strategy-panel-body.se-center-work--list {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우측 — 전략지표 팔레트 */
|
||||||
|
.seval-right-tab-panel--indicators {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.seval-indicator-palette {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--se-panel-card-bg);
|
||||||
|
border: 1px solid var(--se-panel-card-border, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── (legacy) LOGIC EXPRESSION 미리보기 ── */
|
||||||
.seval-condition-panel {
|
.seval-condition-panel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: min(38vh, 320px);
|
max-height: min(38vh, 320px);
|
||||||
|
|||||||
Reference in New Issue
Block a user