전략평가 메뉴 추가

This commit is contained in:
Macbook
2026-06-12 14:39:17 +09:00
parent cb1bde2563
commit ae9266bd28
23 changed files with 1977 additions and 19 deletions
@@ -0,0 +1,186 @@
/**
* 전략 평가 — 캔들 pane 세로 선택 영역 (드래그로 봉 이동)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import type { ChartManager } from '../../utils/ChartManager';
import type { OHLCVBar } from '../../types';
import { findNearestBarIndex } from '../../utils/analysisChartData';
interface Props {
manager: ChartManager;
bars: OHLCVBar[];
selectedBarIndex: number;
onSelectedBarIndexChange: (index: number) => void;
}
interface BarBand {
left: number;
width: number;
top: number;
height: number;
}
function resolveBarBand(
manager: ChartManager,
bars: OHLCVBar[],
index: number,
): BarBand | null {
if (index < 0 || index >= bars.length) return null;
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height < 8) return null;
const centerX = manager.timeToX(bars[index].time);
if (centerX == null || !Number.isFinite(centerX)) return null;
let halfWidth = 7;
if (index > 0) {
const prevX = manager.timeToX(bars[index - 1].time);
if (prevX != null) halfWidth = Math.max(5, Math.abs(centerX - prevX) * 0.42);
}
if (index < bars.length - 1) {
const nextX = manager.timeToX(bars[index + 1].time);
if (nextX != null) halfWidth = Math.max(halfWidth, Math.abs(nextX - centerX) * 0.42);
}
const plotWidth = manager.getCandlePaneTimeAxisBand()?.plotWidth
?? Math.max(40, manager.getContainer().clientWidth - 64);
const width = Math.min(Math.max(halfWidth * 2, 10), 48);
const left = Math.max(0, Math.min(plotWidth - width, centerX - width / 2));
return {
left,
width,
top: main.topY,
height: main.height,
};
}
function indexFromClientX(
manager: ChartManager,
bars: OHLCVBar[],
clientX: number,
): number {
const rect = manager.getContainer().getBoundingClientRect();
const chartX = clientX - rect.left;
const time = manager.xToTime(chartX);
if (time == null) return -1;
return findNearestBarIndex(bars, time);
}
const StrategyEvaluationBarSelector: React.FC<Props> = ({
manager,
bars,
selectedBarIndex,
onSelectedBarIndexChange,
}) => {
const [band, setBand] = useState<BarBand | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const draggingRef = useRef(false);
const displayIndex = dragIndex ?? selectedBarIndex;
const refreshBand = useCallback(() => {
setBand(resolveBarBand(manager, bars, displayIndex));
}, [manager, bars, displayIndex]);
useEffect(() => {
refreshBand();
const ro = new ResizeObserver(refreshBand);
ro.observe(manager.getContainer());
let raf = 0;
const schedule = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(refreshBand);
};
const unsubLayout = manager.subscribePaneLayout(schedule);
const unsubViewport = manager.subscribeViewport(schedule);
const timers = [0, 80, 200].map(ms => window.setTimeout(refreshBand, ms));
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
unsubLayout();
unsubViewport();
timers.forEach(clearTimeout);
};
}, [manager, refreshBand]);
const finishDrag = useCallback((clientX: number) => {
draggingRef.current = false;
setDragIndex(null);
const idx = indexFromClientX(manager, bars, clientX);
if (idx >= 0 && idx !== selectedBarIndex) {
onSelectedBarIndexChange(idx);
}
}, [manager, bars, selectedBarIndex, onSelectedBarIndexChange]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
if (!draggingRef.current) return;
ev.preventDefault();
ev.stopPropagation();
const idx = indexFromClientX(manager, bars, ev.clientX);
if (idx >= 0) setDragIndex(idx);
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
finishDrag(ev.clientX);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
}, [manager, bars, finishDrag]);
if (!band || bars.length === 0) return null;
const bar = bars[displayIndex];
const timeLabel = bar
? new Date(bar.time * 1000).toLocaleString('ko-KR', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '';
return ReactDOM.createPortal(
<div className="seval-bar-selector-layer" aria-hidden>
<div
data-no-chart-pan
className={`seval-bar-selector${dragIndex != null ? ' seval-bar-selector--dragging' : ''}`}
style={{
left: `${band.left}px`,
top: `${band.top}px`,
width: `${band.width}px`,
height: `${band.height}px`,
}}
onPointerDown={onPointerDown}
title="드래그하여 분석 봉 이동"
>
<div className="seval-bar-selector__shine" />
<div className="seval-bar-selector__handle" />
{timeLabel && (
<div className="seval-bar-selector__label">{timeLabel}</div>
)}
</div>
</div>,
manager.getContainer(),
);
};
export default StrategyEvaluationBarSelector;
@@ -0,0 +1,456 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import TradingChart from '../TradingChart';
import { MarketSearchPanel } from '../MarketSearchPanel';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
import { loadAnalysisCandles } from '../../utils/analysisChartData';
import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings';
import {
chartPaneFlexRatio,
countNonOverlayIndicatorPanes,
} from '../../utils/strategyOscillatorSeries';
import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { loadBacktestSettings, runBacktest } from '../../utils/backendApi';
import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators';
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import StrategyEvaluationBarSelector from './StrategyEvaluationBarSelector';
import {
parseBacktestRunTimeframeChoice,
type BacktestRunTimeframeChoice,
} from '../../utils/backtestRunTimeframe';
interface Props {
market: string;
timeframe: Timeframe;
timeframeChoice: BacktestRunTimeframeChoice;
tfOptions: { value: string; label: string }[];
onMarketChange: (market: string) => void;
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
theme?: Theme;
strategy: StrategyDto | null;
selectedBarIndex: number;
onSelectedBarIndexChange: (index: number) => void;
onBarsLoaded?: (bars: OHLCVBar[]) => void;
paramsRevision?: number;
getParamsOverride?: (
type: string,
defaults?: Record<string, number | string | boolean>,
) => Record<string, number | string | boolean>;
getVisualConfigOverride?: (
type: string,
plots: import('../../utils/indicatorRegistry').PlotDef[],
hlines: import('../../utils/indicatorRegistry').HLineDef[],
) => {
plots: import('../../utils/indicatorRegistry').PlotDef[];
hlines: import('../../utils/indicatorRegistry').HLineDef[];
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
};
}
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
function isKeyboardTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
const StrategyEvaluationChart: React.FC<Props> = ({
market,
timeframe,
timeframeChoice,
tfOptions,
onMarketChange,
onTimeframeChoiceChange,
theme = 'dark',
strategy,
selectedBarIndex,
onSelectedBarIndexChange,
onBarsLoaded,
paramsRevision = 0,
getParamsOverride,
getVisualConfigOverride,
}) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams;
const getVisualConfig = getVisualConfigOverride ?? baseGetVisual;
const { defaults: appDefaults } = useAppSettings();
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
const [showMarketSearch, setShowMarketSearch] = useState(false);
const [marketQuery, setMarketQuery] = useState('');
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null);
const marketBtnRef = useRef<HTMLButtonElement>(null);
const managerRef = useRef<ChartManager | null>(null);
const backtestGenRef = useRef(0);
const signalsRef = useRef(signals);
signalsRef.current = signals;
const chartType: ChartType = 'candlestick';
const indicators = useMemo(() => {
if (!strategy) return [] as IndicatorConfig[];
return buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
}, [strategy, getParams, getVisualConfig]);
const auxPaneCount = useMemo(
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
[indicators],
);
const paneAreaRatio = useMemo(() => {
if (auxPaneCount <= 0) return null;
const flex = chartPaneFlexRatio(auxPaneCount);
return { candle: flex.candle, aux: flex.aux };
}, [auxPaneCount]);
const indicatorParams = useMemo(
() => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : {}),
[strategy, getParams],
);
const applyMarkers = useCallback((attempt = 0) => {
const mgr = managerRef.current;
if (!mgr?.hasMainSeries()) {
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
return;
}
if (!strategy) {
mgr.clearBacktestMarkers();
return;
}
mgr.setBacktestMarkers(signalsRef.current, true);
}, [strategy]);
const runBacktestForBars = useCallback(async (barData: OHLCVBar[]) => {
if (!strategy?.id || barData.length < 10) {
setSignals([]);
managerRef.current?.clearBacktestMarkers();
return;
}
const gen = ++backtestGenRef.current;
setBacktestRunning(true);
setBacktestError(null);
try {
const btSettings = await loadBacktestSettings();
const { evaluationBarCount } = resolveEvaluationFromLoadedBars(
barData.length,
strategy.buyCondition,
strategy.sellCondition,
indicatorParams,
);
const res = await runBacktest({
strategyId: strategy.id,
bars: barData.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe,
symbol: market,
strategyName: strategy.name,
settings: btSettings,
indicatorParams,
evaluationBarCount,
});
if (gen !== backtestGenRef.current) return;
if (!res) {
setSignals([]);
setBacktestError('시그널 계산에 실패했습니다.');
managerRef.current?.clearBacktestMarkers();
return;
}
setSignals(res.signals);
applyMarkers();
} catch (e) {
if (gen !== backtestGenRef.current) return;
setSignals([]);
setBacktestError(e instanceof Error ? e.message : '시그널 계산 실패');
managerRef.current?.clearBacktestMarkers();
} finally {
if (gen === backtestGenRef.current) setBacktestRunning(false);
}
}, [strategy, market, timeframe, indicatorParams, applyMarkers]);
useEffect(() => {
if (!strategy?.id || loading || bars.length < 10) {
if (!strategy?.id) {
++backtestGenRef.current;
setSignals([]);
setBacktestError(null);
setBacktestRunning(false);
managerRef.current?.clearBacktestMarkers();
}
return;
}
void runBacktestForBars(bars);
return () => { ++backtestGenRef.current; };
}, [strategy?.id, bars, loading, runBacktestForBars]);
useEffect(() => {
applyMarkers();
}, [signals, applyMarkers]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const toTimeSec = Math.floor(Date.now() / 1000);
void loadAnalysisCandles(market, timeframe, toTimeSec, 300)
.then(data => {
if (cancelled) return;
setBars(data);
onBarsLoaded?.(data);
if (data.length > 0) {
onSelectedBarIndexChange(data.length - 1);
}
})
.catch(e => {
if (cancelled) return;
setError(e instanceof Error ? e.message : '차트 로드 실패');
setBars([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]);
const scrollToBar = useCallback((barIdx: number) => {
const mgr = chartMgr;
if (!mgr || barIdx < 0 || barIdx >= bars.length) return;
const bar = bars[barIdx];
const barSec = Math.max(60, 180);
mgr.applyVisibleTimeRange(bar.time - barSec * 24, bar.time + barSec * 8);
}, [chartMgr, bars]);
useEffect(() => {
if (bars.length === 0) return;
scrollToBar(Math.min(Math.max(0, selectedBarIndex), bars.length - 1));
}, [selectedBarIndex, bars, scrollToBar]);
const onManagerReady = useCallback((mgr: ChartManager) => {
managerRef.current = mgr;
setChartMgr(mgr);
if (paneAreaRatio && paneAreaRatio.aux > 0) {
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
}
applyMarkers();
}, [paneAreaRatio, applyMarkers]);
const onCandlesReady = useCallback(() => {
const mgr = managerRef.current;
if (!mgr) return;
if (paneAreaRatio && paneAreaRatio.aux > 0) {
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
}
applyMarkers();
}, [paneAreaRatio, applyMarkers]);
const stepBar = useCallback((delta: number) => {
if (bars.length === 0) return;
const next = Math.min(bars.length - 1, Math.max(0, selectedBarIndex + delta));
onSelectedBarIndexChange(next);
}, [bars.length, selectedBarIndex, onSelectedBarIndexChange]);
const goLatest = useCallback(() => {
if (bars.length === 0) return;
onSelectedBarIndexChange(bars.length - 1);
}, [bars.length, onSelectedBarIndexChange]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (isKeyboardTypingTarget(e.target)) return;
if (bars.length === 0) return;
if (e.key === 'ArrowLeft') {
if (selectedBarIndex <= 0) return;
e.preventDefault();
stepBar(-1);
return;
}
if (selectedBarIndex >= bars.length - 1) return;
e.preventDefault();
stepBar(1);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [bars.length, selectedBarIndex, stepBar]);
const selectedBar = bars[selectedBarIndex] ?? null;
return (
<div className="seval-chart btd-analysis-chart">
<div className="seval-chart-toolbar btd-analysis-toolbar">
<div className="seval-chart-toolbar-run">
<button
ref={marketBtnRef}
type="button"
className="btd-run-market-btn"
title="종목 검색"
onClick={() => {
setMarketQuery('');
setShowMarketSearch(v => !v);
}}
>
<span className="btd-run-market-ko">{getKoreanName(market)}</span>
<span className="btd-run-market-sym">{market.replace('KRW-', '')}</span>
<svg className="btd-run-market-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<select
className="btd-run-select"
value={timeframeChoice}
onChange={e => onTimeframeChoiceChange(parseBacktestRunTimeframeChoice(e.target.value))}
title="차트 시간봉"
>
{tfOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{selectedBar && (
<span className="seval-chart-bar-time">
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span>
)}
{strategy && signals.length > 0 && !backtestRunning && (
<span className="seval-chart-signal-count" title="백테스트 매매 시그널">
{signals.length}
</span>
)}
{strategy && backtestRunning && (
<span className="seval-chart-backtest-busy"> </span>
)}
</div>
<span className="seval-chart-hint"> · · </span>
</div>
<div className="btd-analysis-main">
<div className="seval-chart-body btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live">
{loading && <div className="seval-chart-status"> </div>}
{error && !loading && <div className="btd-analysis-error">{error}</div>}
{backtestError && !loading && !backtestRunning && !error && (
<div className="seval-chart-status seval-chart-status--error">{backtestError}</div>
)}
{!loading && !error && bars.length > 0 && (
<>
<TradingChart
key={`${market}-${timeframe}-${strategy?.id ?? 0}-${paramsRevision}-${indicators.map(i => i.id).join(',')}`}
bars={bars}
barsMarket={market}
market={market}
timeframe={timeframe}
chartType={chartType}
theme={theme}
mode="chart"
indicators={indicators}
drawingTool="cursor"
drawings={[]}
logScale={false}
onCrosshair={() => {}}
onManagerReady={onManagerReady}
onCandlesReady={onCandlesReady}
onAddDrawing={() => {}}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
volumeVisible={false}
paneSeparatorOptions={appDefaults.chartPaneSeparator}
paneLayoutClamp
showPaneResizeHandle={auxPaneCount > 0}
paneAreaRatio={paneAreaRatio}
showPaneLegend
crosshairInfoVisible={false}
showHoverToolbar={false}
showChartRightToolbar={false}
/>
{chartMgr && (
<StrategyEvaluationBarSelector
manager={chartMgr}
bars={bars}
selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={onSelectedBarIndexChange}
/>
)}
</>
)}
{!loading && !error && bars.length === 0 && (
<div className="btd-analysis-error"> .</div>
)}
</div>
</div>
<div className="seval-chart-bottom-toolbar">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 (←)"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 (→)"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
</div>
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={market}
query={marketQuery}
onQueryChange={setMarketQuery}
onSelect={m => {
onMarketChange(m);
setShowMarketSearch(false);
setMarketQuery('');
}}
onClose={() => {
setShowMarketSearch(false);
setMarketQuery('');
}}
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
anchorPlacement="dropdown"
/>,
document.body,
)}
</div>
);
};
export default StrategyEvaluationChart;
@@ -0,0 +1,187 @@
/**
* 전략 평가 — 좌측 패널 전략설정 탭 (지표 파라미터 테스트)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { getGlobalParamKeys, getPlotParamKeys } from '../../utils/indicatorSettingsLayout';
import { GlobalParamRow } from '../IndicatorSettingsSections';
import { collectStrategyRegistryTypes } from '../../utils/strategyToChartIndicators';
import {
buildEvalParamsFromStrategy,
hasEvalParamsDiff,
type EvalIndicatorParams,
} from '../../utils/strategyEvaluationParams';
import { repairUtf8Mojibake } from '../../utils/textEncoding';
interface Props {
strategy: StrategyDto | null;
getParams: (type: string) => Record<string, number | string | boolean>;
appliedParams: EvalIndicatorParams | null;
onSave: (params: EvalIndicatorParams) => void;
onReset: () => void;
}
function getEditableParamKeys(
indicatorType: string,
params: Record<string, number | string | boolean>,
): string[] {
const def = getIndicatorDef(indicatorType);
const plots = def?.plots ?? [];
const keys = new Set<string>();
for (const k of getGlobalParamKeys(indicatorType, params, plots)) keys.add(k);
plots.forEach((p, i) => {
for (const k of getPlotParamKeys(indicatorType, p.id, i, plots, params)) keys.add(k);
});
if (keys.size === 0) {
Object.keys(params).forEach(k => {
if (k !== 'symbolMode' && k !== 'refSymbol') keys.add(k);
});
}
return [...keys];
}
const StrategyEvaluationSettingsTab: React.FC<Props> = ({
strategy,
getParams,
appliedParams,
onSave,
onReset,
}) => {
const [draft, setDraft] = useState<EvalIndicatorParams>({});
const [expanded, setExpanded] = useState<string | null>(null);
const indicatorTypes = useMemo(
() => collectStrategyRegistryTypes(strategy ?? undefined),
[strategy],
);
useEffect(() => {
if (!strategy) {
setDraft({});
setExpanded(null);
return;
}
const baseline = appliedParams ?? buildEvalParamsFromStrategy(strategy, getParams);
setDraft(JSON.parse(JSON.stringify(baseline)) as EvalIndicatorParams);
setExpanded(indicatorTypes[0] ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [strategy?.id, appliedParams, indicatorTypes.join(',')]);
const dirty = useMemo(
() => hasEvalParamsDiff(draft, appliedParams),
[draft, appliedParams],
);
const patchParam = useCallback((type: string, key: string, raw: string | boolean) => {
setDraft(prev => {
const current = prev[type] ?? {};
const oldVal = current[key];
const defVal = getIndicatorDef(type)?.defaultParams?.[key];
let nextVal: number | string | boolean;
if (typeof oldVal === 'number' || typeof defVal === 'number') {
const n = parseFloat(raw as string);
const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number);
nextVal = Number.isNaN(n) ? fallback : n;
} else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') {
nextVal = raw as boolean;
} else {
nextVal = raw as string;
}
return {
...prev,
[type]: { ...current, [key]: nextVal },
};
});
}, []);
const resetType = useCallback((type: string) => {
setDraft(prev => ({
...prev,
[type]: { ...getParams(type) },
}));
}, [getParams]);
if (!strategy) {
return <p className="btd-sidebar-empty"> </p>;
}
if (indicatorTypes.length === 0) {
return <p className="btd-sidebar-empty"> </p>;
}
const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`);
return (
<div className="seval-settings-tab">
<div className="seval-settings-head">
<p className="seval-settings-strategy">{strategyName}</p>
<p className="seval-settings-hint"> · </p>
</div>
<div className="seval-settings-list">
{indicatorTypes.map(type => {
const def = getIndicatorDef(type);
const params = draft[type] ?? {};
const paramKeys = getEditableParamKeys(type, params);
const isOpen = expanded === type;
const label = def?.koreanName ?? def?.shortName ?? type;
return (
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
<button
type="button"
className="seval-settings-card-head"
onClick={() => setExpanded(isOpen ? null : type)}
>
<span className="seval-settings-card-title">{label}</span>
<span className="seval-settings-card-type">{type}</span>
</button>
{isOpen && (
<div className="seval-settings-card-body">
{paramKeys.map(key => (
<GlobalParamRow
key={key}
indicatorType={type}
paramKey={key}
value={params[key] ?? getParams(type)[key]}
onChange={(k, raw) => patchParam(type, k, raw)}
/>
))}
<button
type="button"
className="seval-settings-reset-type"
onClick={() => resetType(type)}
>
</button>
</div>
)}
</div>
);
})}
</div>
<div className="seval-settings-actions">
<button
type="button"
className="seval-settings-save"
disabled={!dirty}
onClick={() => onSave(JSON.parse(JSON.stringify(draft)) as EvalIndicatorParams)}
>
·
</button>
<button
type="button"
className="seval-settings-reset"
disabled={!appliedParams && !dirty}
onClick={onReset}
>
</button>
</div>
</div>
);
};
export default StrategyEvaluationSettingsTab;