전략평가 화면 수정
This commit is contained in:
@@ -193,8 +193,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
|
||||
|
||||
const barSignalHighlight = useMemo(
|
||||
() => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec),
|
||||
[backtestSignals, selectedBarTimeSec],
|
||||
() => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec, selectedBarIndex),
|
||||
[backtestSignals, selectedBarTimeSec, selectedBarIndex],
|
||||
);
|
||||
|
||||
const handleSignalsChange = useCallback((signals: BacktestSignal[]) => {
|
||||
@@ -450,19 +450,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
{evalError && <p className="seval-eval-error">{evalError}</p>}
|
||||
|
||||
<div className="seval-signal-body">
|
||||
{evalLoading && (
|
||||
<div className="seval-eval-loading" aria-live="polite" aria-busy="true">
|
||||
<span className="btd-run-spinner seval-eval-loading-spinner" aria-hidden />
|
||||
<span className="seval-eval-loading-text">전략 조건 확인 중…</span>
|
||||
</div>
|
||||
)}
|
||||
<StrategyEvaluationSignalPanel
|
||||
snapshot={snapshot}
|
||||
barSignalHighlight={barSignalHighlight}
|
||||
loading={evalLoading}
|
||||
loadingMessage="전략 조건 확인 중…"
|
||||
hasStrategy={selectedStrategyId != null}
|
||||
className={`seval-signal-panel${evalLoading ? ' seval-signal-panel--loading' : ''}`}
|
||||
className="seval-signal-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,6 +69,20 @@ function indexFromClientX(
|
||||
return findNearestBarIndex(bars, time);
|
||||
}
|
||||
|
||||
/** 캔들 pane 클릭 좌표 → 봉 index (전략선택봉 이동) */
|
||||
export function resolveBarIndexAtChartClick(
|
||||
manager: ChartManager,
|
||||
bars: OHLCVBar[],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): number {
|
||||
const rect = manager.getContainer().getBoundingClientRect();
|
||||
const clickY = clientY - rect.top;
|
||||
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
|
||||
if (!main || clickY < main.topY || clickY >= main.topY + main.height) return -1;
|
||||
return indexFromClientX(manager, bars, clientX);
|
||||
}
|
||||
|
||||
const StrategyEvaluationBarSelector: React.FC<Props> = ({
|
||||
manager,
|
||||
bars,
|
||||
|
||||
@@ -20,7 +20,9 @@ import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartI
|
||||
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
|
||||
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
|
||||
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||
import StrategyEvaluationBarSelector from './StrategyEvaluationBarSelector';
|
||||
import StrategyEvaluationBarSelector, {
|
||||
resolveBarIndexAtChartClick,
|
||||
} from './StrategyEvaluationBarSelector';
|
||||
import {
|
||||
parseBacktestRunTimeframeChoice,
|
||||
type BacktestRunTimeframeChoice,
|
||||
@@ -300,6 +302,26 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
onSelectedBarIndexChange(bars.length - 1);
|
||||
}, [bars.length, onSelectedBarIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const mgr = chartMgr;
|
||||
if (!mgr || bars.length === 0) return;
|
||||
|
||||
const onChartClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-no-chart-pan]')) return;
|
||||
|
||||
const idx = resolveBarIndexAtChartClick(mgr, bars, e.clientX, e.clientY);
|
||||
if (idx >= 0 && idx !== selectedBarIndex) {
|
||||
onSelectedBarIndexChange(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const container = mgr.getContainer();
|
||||
container.addEventListener('click', onChartClick);
|
||||
return () => container.removeEventListener('click', onChartClick);
|
||||
}, [chartMgr, bars, selectedBarIndex, onSelectedBarIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
@@ -372,10 +394,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
))}
|
||||
</select>
|
||||
{selectedBar && (
|
||||
<span className={`seval-chart-bar-time${evalLoading ? ' seval-chart-bar-time--loading' : ''}`}>
|
||||
{evalLoading && (
|
||||
<span className="btd-run-spinner seval-chart-bar-spinner" aria-hidden />
|
||||
)}
|
||||
<span className="seval-chart-bar-time">
|
||||
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
@@ -388,11 +407,17 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
<span className="seval-chart-backtest-busy">시그널 계산 중…</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="seval-chart-hint">세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동</span>
|
||||
<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">
|
||||
{evalLoading && (
|
||||
<div className="seval-chart-eval-backdrop" aria-live="polite" aria-busy="true">
|
||||
<span className="btd-run-spinner seval-chart-eval-backdrop-spinner" aria-hidden />
|
||||
<span className="seval-chart-eval-backdrop-text">전략 조건 확인 중…</span>
|
||||
</div>
|
||||
)}
|
||||
{loading && <div className="seval-chart-status">차트 로딩…</div>}
|
||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||
{backtestError && !loading && !backtestRunning && !error && (
|
||||
|
||||
@@ -2,12 +2,9 @@ import React, { useMemo } from 'react';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
getTrafficLightState,
|
||||
type ConditionMetric,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualConditionList from '../virtual/VirtualConditionList';
|
||||
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
|
||||
import VirtualSignalMatchVisual from '../virtual/VirtualSignalMatchVisual';
|
||||
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
|
||||
|
||||
interface Props {
|
||||
@@ -28,8 +25,6 @@ interface SideCardProps {
|
||||
}
|
||||
|
||||
const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalActive = false }) => {
|
||||
const matchRate = useMemo(() => computeMatchRate(metrics), [metrics]);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const title = side === 'buy' ? '매수 조건' : '매도 조건';
|
||||
const overallLabel =
|
||||
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
|
||||
@@ -41,7 +36,7 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
|
||||
`seval-side-card--${side}`,
|
||||
signalActive ? 'seval-side-card--signal-active' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-label={`${title} 지표 목록${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
|
||||
aria-label={`${title} 지표 일치율${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
|
||||
>
|
||||
<header className="seval-side-card-head">
|
||||
<div className="seval-side-card-head-main">
|
||||
@@ -55,12 +50,13 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="seval-side-card-head-signal">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="seval-side-card-body">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalMatchVisual
|
||||
metrics={metrics}
|
||||
title="SIGNAL INTELLIGENCE & MATCH RATES"
|
||||
panelClassName="seval-side-sig-panel"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
getTrafficLightState,
|
||||
type ConditionMetric,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
|
||||
import VirtualConditionList from './VirtualConditionList';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
title?: React.ReactNode;
|
||||
/** 백엔드 집계 matchRate — 미지정 시 metrics 로컬 계산 */
|
||||
backendMatchRate?: number | null;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
}
|
||||
|
||||
const VirtualSignalMatchVisual: React.FC<Props> = ({
|
||||
metrics,
|
||||
title,
|
||||
backendMatchRate,
|
||||
className = '',
|
||||
panelClassName = '',
|
||||
}) => {
|
||||
const matchRate = useMemo(
|
||||
() => computeMatchRate(metrics, backendMatchRate),
|
||||
[metrics, backendMatchRate],
|
||||
);
|
||||
const trafficState = useMemo(
|
||||
() => getTrafficLightState(matchRate, metrics),
|
||||
[matchRate, metrics],
|
||||
);
|
||||
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||
metrics.length,
|
||||
matchRate,
|
||||
trafficState,
|
||||
title,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={['vtd-sig-panel', panelClassName || 'vtd-sig-panel--summary'].filter(Boolean).join(' ')}>
|
||||
{title != null && title !== '' && (
|
||||
<div className="vtd-sig-panel-title">{title}</div>
|
||||
)}
|
||||
<div className={['vtd-sig-visual', className].filter(Boolean).join(' ')} ref={visualRef}>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** rows → metrics 변환 후 시각 블록 렌더 (편의 래퍼) */
|
||||
export function VirtualSignalMatchVisualFromRows({
|
||||
rows,
|
||||
title,
|
||||
backendMatchRate,
|
||||
className,
|
||||
panelClassName,
|
||||
}: {
|
||||
rows: Parameters<typeof buildConditionMetrics>[0];
|
||||
title?: React.ReactNode;
|
||||
backendMatchRate?: number | null;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
}) {
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
return (
|
||||
<VirtualSignalMatchVisual
|
||||
metrics={metrics}
|
||||
title={title}
|
||||
backendMatchRate={backendMatchRate}
|
||||
className={className}
|
||||
panelClassName={panelClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VirtualSignalMatchVisual;
|
||||
@@ -1,14 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
getTrafficLightState,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
|
||||
import VirtualConditionList from './VirtualConditionList';
|
||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalMatchVisual from './VirtualSignalMatchVisual';
|
||||
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
|
||||
@@ -38,17 +31,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
const isDetail = viewMode === 'detail';
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
const matchRate = useMemo(
|
||||
() => computeMatchRate(metrics, snapshot?.matchRate),
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||
rows.length,
|
||||
matchRate,
|
||||
trafficState,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -71,22 +53,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual" ref={visualRef}>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VirtualSignalMatchVisual
|
||||
metrics={metrics}
|
||||
title="SIGNAL INTELLIGENCE & MATCH RATES"
|
||||
backendMatchRate={snapshot?.matchRate}
|
||||
panelClassName={isDetail ? 'vtd-sig-panel--detail-top' : 'vtd-sig-panel--summary'}
|
||||
/>
|
||||
{isDetail && (
|
||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||
<VirtualIndicatorCompareTable metrics={metrics} />
|
||||
|
||||
Reference in New Issue
Block a user