전략평가 화면에서 전략조건 수정

This commit is contained in:
Macbook
2026-06-18 10:40:59 +09:00
parent 615f509f7d
commit 97a1549647
5 changed files with 1064 additions and 7 deletions
@@ -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,
};
}