전략평가 화면에서 전략조건 수정
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user