전략평가 메뉴 추가
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
|
||||
*/
|
||||
import '../styles/strategyEvaluation.css';
|
||||
import '../styles/backtestDashboard.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme, Timeframe } from '../types';
|
||||
import {
|
||||
loadStrategies,
|
||||
loadStrategy,
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import { hydrateStrategyDto } from '../utils/strategyHydrate';
|
||||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
|
||||
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
|
||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
|
||||
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
||||
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import {
|
||||
mergeEvalGetParams,
|
||||
type EvalIndicatorParams,
|
||||
} from '../utils/strategyEvaluationParams';
|
||||
import {
|
||||
BACKTEST_STRATEGY_TIMEFRAME,
|
||||
buildQuickRunTimeframeSelectOptions,
|
||||
resolveBacktestExecTimeframe,
|
||||
type BacktestRunTimeframeChoice,
|
||||
} from '../utils/backtestRunTimeframe';
|
||||
import type { OHLCVBar } from '../types';
|
||||
|
||||
const LEFT_KEY = 'seval-left-width';
|
||||
const RIGHT_KEY = 'seval-right-width';
|
||||
const LEFT_DEFAULT = 380;
|
||||
const RIGHT_DEFAULT = 440;
|
||||
|
||||
type LeftTab = 'strategy' | 'settings';
|
||||
|
||||
function readMinWidth(key: string, fallback: number): number {
|
||||
return Math.max(readStoredSize(key, fallback), fallback);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [strategySearch, setStrategySearch] = useState('');
|
||||
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<StrategyDto | null>(null);
|
||||
|
||||
const [market, setMarket] = useState('KRW-BTC');
|
||||
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
||||
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
||||
const [evalLoading, setEvalLoading] = useState(false);
|
||||
const [evalError, setEvalError] = useState<string | null>(null);
|
||||
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
|
||||
const [paramsRevision, setParamsRevision] = useState(0);
|
||||
|
||||
const getEvalParams = useMemo(
|
||||
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
||||
[baseGetParams, appliedIndicatorParams],
|
||||
);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||
const leftRef = useRef(leftWidth);
|
||||
const rightRef = useRef(rightWidth);
|
||||
leftRef.current = leftWidth;
|
||||
rightRef.current = rightWidth;
|
||||
|
||||
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
|
||||
const onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const startX = e.clientX;
|
||||
const start = rightRef.current;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.add('btd-splitter--active');
|
||||
const splitter = e.currentTarget;
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
setRightWidth(Math.min(600, Math.max(360, start - delta)));
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
splitter.releasePointerCapture(ev.pointerId);
|
||||
splitter.classList.remove('btd-splitter--active');
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
storeSize(RIGHT_KEY, rightRef.current);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, []);
|
||||
|
||||
const strategyPrimaryTimeframe = useMemo(
|
||||
() => (selectedStrategy ? resolveStrategyPrimaryTimeframe(selectedStrategy) : undefined),
|
||||
[selectedStrategy],
|
||||
);
|
||||
|
||||
const chartTimeframe = useMemo((): Timeframe => {
|
||||
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m');
|
||||
}, [timeframeChoice, strategyPrimaryTimeframe]);
|
||||
|
||||
const tfOptions = useMemo(
|
||||
() => buildQuickRunTimeframeSelectOptions(strategyPrimaryTimeframe),
|
||||
[strategyPrimaryTimeframe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedStrategyId) {
|
||||
setSelectedStrategy(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategy(selectedStrategyId).then(s => {
|
||||
if (cancelled) return;
|
||||
setSelectedStrategy(s ? hydrateStrategyDto(s) : null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedStrategyId]);
|
||||
|
||||
const filteredStrategies = useMemo(() => {
|
||||
const q = strategySearch.trim().toLowerCase();
|
||||
if (!q) return strategies;
|
||||
return strategies.filter(s =>
|
||||
(s.name ?? '').toLowerCase().includes(q)
|
||||
|| String(s.id ?? '').includes(q),
|
||||
);
|
||||
}, [strategies, strategySearch]);
|
||||
|
||||
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
|
||||
|
||||
const refreshEvaluation = useCallback(async () => {
|
||||
if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) {
|
||||
setSnapshot(undefined);
|
||||
return;
|
||||
}
|
||||
setEvalLoading(true);
|
||||
setEvalError(null);
|
||||
try {
|
||||
const snap = await fetchEvaluationSnapshotAtBar(
|
||||
market,
|
||||
selectedStrategyId,
|
||||
selectedStrategy,
|
||||
selectedBarTimeSec,
|
||||
appliedIndicatorParams,
|
||||
);
|
||||
setSnapshot(snap ?? undefined);
|
||||
} catch (e) {
|
||||
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
|
||||
setSnapshot(undefined);
|
||||
} finally {
|
||||
setEvalLoading(false);
|
||||
}
|
||||
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEvaluation();
|
||||
}, [refreshEvaluation]);
|
||||
|
||||
const handleSelectStrategy = useCallback((s: StrategyDto) => {
|
||||
if (s.id == null) return;
|
||||
setSelectedStrategyId(s.id);
|
||||
setSelectedStrategy(hydrateStrategyDto(s));
|
||||
setAppliedIndicatorParams(null);
|
||||
setParamsRevision(v => v + 1);
|
||||
setLeftTab('strategy');
|
||||
}, []);
|
||||
|
||||
const handleSaveIndicatorParams = useCallback((params: EvalIndicatorParams) => {
|
||||
setAppliedIndicatorParams(params);
|
||||
setParamsRevision(v => v + 1);
|
||||
}, []);
|
||||
|
||||
const handleResetIndicatorParams = useCallback(() => {
|
||||
setAppliedIndicatorParams(null);
|
||||
setParamsRevision(v => v + 1);
|
||||
}, []);
|
||||
|
||||
const strategyLabel = selectedStrategy?.name
|
||||
? repairUtf8Mojibake(selectedStrategy.name)
|
||||
: '전략 미선택';
|
||||
|
||||
return (
|
||||
<div className={`btd-page se-page se-page--${theme}`}>
|
||||
<header className="btd-header">
|
||||
<div className="btd-header-brand">
|
||||
<h1 className="btd-header-title">전략 평가</h1>
|
||||
<span className="btd-header-sub">Strategy Match Analysis</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="btd-body">
|
||||
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
|
||||
<div className="btd-exec-list seval-left-panel">
|
||||
<div className="btd-exec-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'strategy'}
|
||||
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('strategy')}
|
||||
>
|
||||
전략
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'settings'}
|
||||
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('settings')}
|
||||
disabled={!selectedStrategy}
|
||||
>
|
||||
전략설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{leftTab === 'strategy' ? (
|
||||
<>
|
||||
<div className="btd-exec-search">
|
||||
<span className="btd-exec-search-icon" aria-hidden>⌕</span>
|
||||
<input
|
||||
className="btd-exec-search-input"
|
||||
placeholder="전략 검색…"
|
||||
value={strategySearch}
|
||||
onChange={e => setStrategySearch(e.target.value)}
|
||||
/>
|
||||
{strategySearch && (
|
||||
<button
|
||||
type="button"
|
||||
className="btd-exec-search-clear"
|
||||
onClick={() => setStrategySearch('')}
|
||||
aria-label="검색어 지우기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="btd-exec-scroll">
|
||||
{filteredStrategies.length === 0 ? (
|
||||
<p className="btd-sidebar-empty">저장된 전략이 없습니다</p>
|
||||
) : (
|
||||
filteredStrategies.map(s => {
|
||||
const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`);
|
||||
const active = selectedStrategyId === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`btd-exec-item${active ? ' btd-exec-item--active' : ''}`}
|
||||
onClick={() => handleSelectStrategy(s)}
|
||||
>
|
||||
<div className="btd-exec-item-top">
|
||||
<span className="btd-exec-strategy">{name}</span>
|
||||
</div>
|
||||
{s.description && (
|
||||
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<StrategyEvaluationSettingsTab
|
||||
strategy={selectedStrategy}
|
||||
getParams={baseGetParams}
|
||||
appliedParams={appliedIndicatorParams}
|
||||
onSave={handleSaveIndicatorParams}
|
||||
onReset={handleResetIndicatorParams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="btd-splitter btd-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="좌측 패널 너비"
|
||||
onPointerDown={onLeftSplit}
|
||||
/>
|
||||
|
||||
<main className="btd-main">
|
||||
<div className="btd-main-content seval-main-content">
|
||||
<StrategyEvaluationChart
|
||||
market={market}
|
||||
timeframe={chartTimeframe}
|
||||
timeframeChoice={timeframeChoice}
|
||||
tfOptions={tfOptions}
|
||||
onMarketChange={setMarket}
|
||||
onTimeframeChoiceChange={setTimeframeChoice}
|
||||
theme={theme}
|
||||
strategy={selectedStrategy}
|
||||
selectedBarIndex={selectedBarIndex}
|
||||
onSelectedBarIndexChange={setSelectedBarIndex}
|
||||
onBarsLoaded={setBars}
|
||||
paramsRevision={paramsRevision}
|
||||
getParamsOverride={getEvalParams}
|
||||
getVisualConfigOverride={getVisualConfig}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
className="btd-splitter btd-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="우측 패널 너비"
|
||||
onPointerDown={onRightSplit}
|
||||
/>
|
||||
|
||||
<aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
|
||||
<div className="seval-signal-head">
|
||||
<span className="seval-signal-market">{getKoreanName(market)}</span>
|
||||
<span className="seval-live-dot" title="선택 봉 기준 평가" />
|
||||
<span className="seval-live-label">봉 평가</span>
|
||||
</div>
|
||||
|
||||
{evalError && <p className="seval-eval-error">{evalError}</p>}
|
||||
|
||||
<VirtualTargetSignalPanel
|
||||
snapshot={snapshot}
|
||||
viewMode="summary"
|
||||
loading={evalLoading}
|
||||
hasStrategy={selectedStrategyId != null}
|
||||
className="seval-signal-panel"
|
||||
/>
|
||||
|
||||
<footer className="seval-signal-footer">
|
||||
<span className="seval-updated">
|
||||
{selectedBarTimeSec != null
|
||||
? new Date(selectedBarTimeSec * 1000).toLocaleString('ko-KR', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '—'}
|
||||
{evalLoading ? ' · 평가 중…' : snapshot?.updatedAt
|
||||
? ` · 갱신 ${new Date(snapshot.updatedAt).toLocaleTimeString('ko-KR')}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="seval-strategy-chip">{strategyLabel}</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
@@ -152,6 +152,18 @@ const IcStrategyEditor = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcStrategyEvaluation = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="1.5" width="5" height="13" rx="1"/>
|
||||
<line x1="3.5" y1="13" x2="3.5" y2="7"/>
|
||||
<line x1="5.5" y1="13" x2="5.5" y2="5"/>
|
||||
<rect x="8.5" y="3" width="5.5" height="3" rx="0.8"/>
|
||||
<rect x="8.5" y="7.5" width="5.5" height="3" rx="0.8"/>
|
||||
<rect x="8.5" y="12" width="5.5" height="2.5" rx="0.8"/>
|
||||
<polyline points="13.5,4.5 12.5,5.5 11,4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcPaper = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1.5" y="4" width="13" height="9" rx="1.5"/>
|
||||
@@ -191,6 +203,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
|
||||
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'strategy-evaluation', label: '전략 평가', icon: <IcStrategyEvaluation /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'analysis-report', label: '분석레포트', icon: <IcAnalysisReport /> },
|
||||
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
|
||||
|
||||
@@ -1122,7 +1122,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const onPanPointerDown = (e: PointerEvent) => {
|
||||
if (!canPanRef.current || e.button !== 0) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('.chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
|
||||
if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
|
||||
return;
|
||||
}
|
||||
const pt = isChartPlotPointer(e.clientX, e.clientY);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user