diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index de5d3ce..8a77798 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -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 &&

{evalError}

}
- {evalLoading && ( -
- - 전략 조건 확인 중… -
- )}
diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx index 4f61211..567941c 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx @@ -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 = ({ manager, bars, diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index b841aaa..d781817 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -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 = ({ 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 = ({ ))} {selectedBar && ( - - {evalLoading && ( - - )} + {new Date(selectedBar.time * 1000).toLocaleString('ko-KR')} )} @@ -388,11 +407,17 @@ const StrategyEvaluationChart: React.FC = ({ 시그널 계산 중… )} - 세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동 + 캔들 클릭 · 세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동
+ {evalLoading && ( +
+ + 전략 조건 확인 중… +
+ )} {loading &&
차트 로딩…
} {error && !loading &&
{error}
} {backtestError && !loading && !backtestRunning && !error && ( diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index 6d44986..150b6a3 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -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 = ({ 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 = ({ 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 ? ' · 백테스트 시그널 발생 봉' : ''}`} >
@@ -55,12 +50,13 @@ const SideCard: React.FC = ({ side, metrics, overallMet, signalAc )}
-
- -
- +
); diff --git a/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx new file mode 100644 index 0000000..66699f7 --- /dev/null +++ b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx @@ -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 = ({ + 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 ( +
+ {title != null && title !== '' && ( +
{title}
+ )} +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ ); +}; + +/** rows → metrics 변환 후 시각 블록 렌더 (편의 래퍼) */ +export function VirtualSignalMatchVisualFromRows({ + rows, + title, + backendMatchRate, + className, + panelClassName, +}: { + rows: Parameters[0]; + title?: React.ReactNode; + backendMatchRate?: number | null; + className?: string; + panelClassName?: string; +}) { + const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); + return ( + + ); +} + +export default VirtualSignalMatchVisual; diff --git a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx index b97408f..b67a111 100644 --- a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx +++ b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx @@ -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 = ({ 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 = ({ className, ].filter(Boolean).join(' ')} > -
-
SIGNAL INTELLIGENCE & MATCH RATES
-
-
- -
-
- -
-
-
- -
-
-
-
+ {isDetail && (
diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 5d7faa7..8d2c1b9 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -104,6 +104,35 @@ min-height: 0; } +/* 전략 조건 평가 중 — 차트 영역 backdrop */ +.seval-chart-eval-backdrop { + position: absolute; + inset: 0; + z-index: 920; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: color-mix(in srgb, var(--se-bg, #0d1117) 72%, transparent); + backdrop-filter: blur(3px); + pointer-events: none; +} + +.seval-chart-eval-backdrop-spinner { + width: 28px; + height: 28px; + border-width: 2.5px; + color: var(--se-accent, #3d9be9); +} + +.seval-chart-eval-backdrop-text { + font-size: 0.78rem; + font-weight: 600; + color: var(--se-text-muted); + letter-spacing: 0.02em; +} + .seval-chart-toolbar { flex-shrink: 0; display: flex; @@ -341,40 +370,12 @@ } .seval-signal-body { - position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } -.seval-eval-loading { - position: absolute; - inset: 0; - z-index: 6; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - background: color-mix(in srgb, var(--se-bg, #0d1117) 78%, transparent); - backdrop-filter: blur(2px); - pointer-events: none; -} - -.seval-eval-loading-spinner { - width: 18px; - height: 18px; - border-width: 2px; - color: var(--se-accent, #3d9be9); -} - -.seval-eval-loading-text { - font-size: 0.72rem; - font-weight: 600; - color: var(--se-text-muted); -} - .seval-signal-panel { flex: 1; min-height: 0; @@ -384,12 +385,6 @@ padding: 0 2px; } -.seval-signal-panel--loading { - opacity: 0.42; - pointer-events: none; - user-select: none; -} - .seval-eval-error { margin: 0 4px 8px; font-size: 0.72rem; @@ -430,7 +425,8 @@ min-height: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 12px; + padding: 4px 3px; } .seval-side-card { @@ -455,10 +451,10 @@ .seval-side-card-head { flex-shrink: 0; display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 8px; - padding: 8px 10px 6px; + padding: 6px 10px 4px; border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); } @@ -505,43 +501,59 @@ border: 1px solid color-mix(in srgb, var(--se-text-muted) 25%, transparent); } -.seval-side-card-head-signal { - flex-shrink: 0; -} - -.seval-side-card-head-signal .vtd-sig-light--card-right { - align-items: flex-end; -} - -.seval-side-card-head-signal .vtd-sig-light-housing { - transform: scale(0.82); - transform-origin: top right; -} - -.seval-side-card-head-signal .vtd-sig-light-info--below { - align-items: flex-end; - text-align: right; -} - -.seval-side-card-head-signal .vtd-sig-light-rate { - font-size: 1.05rem; -} - -.seval-side-card-head-signal .vtd-sig-light-label { - font-size: 0.58rem; - max-width: 72px; - line-height: 1.2; -} - .seval-side-card-body { flex: 1; min-height: 0; - padding: 4px 8px 8px; + padding: 0 6px 6px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.seval-side-card-body .seval-side-sig-panel { + flex: 1; + min-height: 0; + gap: 4px; +} + +.seval-side-card-body .seval-side-sig-panel .vtd-sig-panel-title { + font-size: 8px; + padding: 2px 0 0; + letter-spacing: 0.06em; +} + +.seval-side-card-body .vtd-sig-visual { + --vtd-sig-rail-height: 120px; + flex: 1; + min-height: 0; + padding: 6px 8px 8px; + gap: 8px 10px; +} + +.seval-side-card-body .vtd-sig-visual-heat { + min-height: 0; + overflow: hidden; +} + +.seval-side-card-body .vtd-sig-visual-heat .vtd-heat-list { + max-height: min(140px, 100%); overflow-y: auto; } -.seval-side-card-body .vtd-heat-list { - max-height: none; +.seval-side-card-body .vtd-sig-visual-eq { + height: var(--vtd-sig-rail-height); + min-height: var(--vtd-sig-rail-height); + max-height: var(--vtd-sig-rail-height); +} + +.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-rate { + font-size: 1.15rem; +} + +.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-label { + font-size: 0.58rem; + max-width: 76px; + line-height: 1.2; } .seval-side-card-body .vtd-heat-list-empty { @@ -550,23 +562,45 @@ text-align: center; } -/* 선택 봉에 백테스트 매매 시그널이 있을 때 — 형광연두 그라데이션 아웃라인·음영 */ +/* 선택 봉에 백테스트 매매 시그널 — 형광연두 아웃라인 + 외곽 glow (첨부 이미지 스타일) */ .seval-side-card--signal-active { position: relative; z-index: 1; - border: 2px solid transparent; - background: - linear-gradient( - 160deg, - color-mix(in srgb, #ccff00 14%, var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent))), - var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent)) - ) padding-box, - linear-gradient(145deg, #e8ff00 0%, #39ff14 38%, #c6ff00 62%, #7fff00 100%) border-box; + border-color: transparent; + isolation: isolate; + overflow: visible; +} + +.seval-side-card--signal-active::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 10px; + padding: 2px; + background: linear-gradient(135deg, #d4ff00 0%, #39ff14 42%, #c6ff00 68%, #7fff00 100%); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + z-index: 3; +} + +.seval-side-card--signal-active::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 14px; + pointer-events: none; + z-index: -1; box-shadow: - 0 0 0 1px color-mix(in srgb, #ccff00 30%, transparent), - 0 0 14px color-mix(in srgb, #ccff00 42%, transparent), - 0 0 28px color-mix(in srgb, #39ff14 22%, transparent), - inset 0 0 32px color-mix(in srgb, #ccff00 11%, transparent); + 0 0 6px 1px rgba(184, 255, 0, 0.85), + 0 0 14px 3px rgba(57, 255, 20, 0.55), + 0 0 28px 5px rgba(204, 255, 0, 0.32); } .seval-side-card--signal-active.seval-side-card--buy, @@ -574,10 +608,6 @@ border-top-color: transparent; } -.seval-side-card--signal-active .seval-side-card-head { - border-bottom-color: color-mix(in srgb, #ccff00 28%, var(--se-border)); -} - .seval-side-card--signal-active .seval-side-card-badge::after { content: ' · 시그널'; font-size: 0.62rem; diff --git a/frontend/src/utils/strategyEvaluationBarSignals.ts b/frontend/src/utils/strategyEvaluationBarSignals.ts index 8027850..0739b33 100644 --- a/frontend/src/utils/strategyEvaluationBarSignals.ts +++ b/frontend/src/utils/strategyEvaluationBarSignals.ts @@ -1,4 +1,5 @@ import type { BacktestSignal } from './backendApi'; +import { normalizeEpochSecOptional } from './backtestUiUtils'; export interface BarSignalHighlight { buy: boolean; @@ -11,15 +12,39 @@ const isBuySignalType = (type: BacktestSignal['type']): boolean => const isSellSignalType = (type: BacktestSignal['type']): boolean => type === 'SELL' || type === 'SHORT_ENTRY' || type === 'PARTIAL_SELL'; -/** 선택 봉 시각에 백테스트 매매 시그널이 있는지 (차트 마커와 동일 기준) */ +function signalMatchesBar( + signal: BacktestSignal, + barIndex: number, + barTimeSec: number, +): boolean { + if (signal.barIndex === barIndex) return true; + const signalTime = normalizeEpochSecOptional(signal.time); + const barTime = normalizeEpochSecOptional(barTimeSec); + if (signalTime > 0 && barTime > 0 && signalTime === barTime) return true; + return false; +} + +/** 선택 봉에 백테스트 매매 시그널이 있는지 (차트 마커와 동일 기준) */ export function resolveBarSignalHighlight( signals: BacktestSignal[], barTimeSec: number | null | undefined, + barIndex?: number | null, ): BarSignalHighlight { - if (barTimeSec == null || signals.length === 0) { + if (signals.length === 0) { return { buy: false, sell: false }; } - const atBar = signals.filter(s => s.time === barTimeSec); + if (barIndex == null || barIndex < 0) { + if (barTimeSec == null) return { buy: false, sell: false }; + const t = normalizeEpochSecOptional(barTimeSec); + const atBar = signals.filter(s => normalizeEpochSecOptional(s.time) === t); + return { + buy: atBar.some(s => isBuySignalType(s.type)), + sell: atBar.some(s => isSellSignalType(s.type)), + }; + } + + const barTime = barTimeSec ?? 0; + const atBar = signals.filter(s => signalMatchesBar(s, barIndex, barTime)); return { buy: atBar.some(s => isBuySignalType(s.type)), sell: atBar.some(s => isSellSignalType(s.type)),