전략평가 화면 수정

This commit is contained in:
Macbook
2026-06-12 15:22:48 +09:00
parent 7579ed9de4
commit 8aae8489ae
8 changed files with 298 additions and 152 deletions
@@ -193,8 +193,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
const barSignalHighlight = useMemo( const barSignalHighlight = useMemo(
() => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec), () => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec, selectedBarIndex),
[backtestSignals, selectedBarTimeSec], [backtestSignals, selectedBarTimeSec, selectedBarIndex],
); );
const handleSignalsChange = useCallback((signals: BacktestSignal[]) => { const handleSignalsChange = useCallback((signals: BacktestSignal[]) => {
@@ -450,19 +450,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
{evalError && <p className="seval-eval-error">{evalError}</p>} {evalError && <p className="seval-eval-error">{evalError}</p>}
<div className="seval-signal-body"> <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 <StrategyEvaluationSignalPanel
snapshot={snapshot} snapshot={snapshot}
barSignalHighlight={barSignalHighlight} barSignalHighlight={barSignalHighlight}
loading={evalLoading}
loadingMessage="전략 조건 확인 중…"
hasStrategy={selectedStrategyId != null} hasStrategy={selectedStrategyId != null}
className={`seval-signal-panel${evalLoading ? ' seval-signal-panel--loading' : ''}`} className="seval-signal-panel"
/> />
</div> </div>
@@ -69,6 +69,20 @@ function indexFromClientX(
return findNearestBarIndex(bars, time); 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> = ({ const StrategyEvaluationBarSelector: React.FC<Props> = ({
manager, manager,
bars, bars,
@@ -20,7 +20,9 @@ import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartI
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams'; import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import { repairUtf8Mojibake } from '../../utils/textEncoding'; import { repairUtf8Mojibake } from '../../utils/textEncoding';
import StrategyEvaluationBarSelector from './StrategyEvaluationBarSelector'; import StrategyEvaluationBarSelector, {
resolveBarIndexAtChartClick,
} from './StrategyEvaluationBarSelector';
import { import {
parseBacktestRunTimeframeChoice, parseBacktestRunTimeframeChoice,
type BacktestRunTimeframeChoice, type BacktestRunTimeframeChoice,
@@ -300,6 +302,26 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onSelectedBarIndexChange(bars.length - 1); onSelectedBarIndexChange(bars.length - 1);
}, [bars.length, onSelectedBarIndexChange]); }, [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(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
@@ -372,10 +394,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
))} ))}
</select> </select>
{selectedBar && ( {selectedBar && (
<span className={`seval-chart-bar-time${evalLoading ? ' seval-chart-bar-time--loading' : ''}`}> <span className="seval-chart-bar-time">
{evalLoading && (
<span className="btd-run-spinner seval-chart-bar-spinner" aria-hidden />
)}
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')} {new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span> </span>
)} )}
@@ -388,11 +407,17 @@ const StrategyEvaluationChart: React.FC<Props> = ({
<span className="seval-chart-backtest-busy"> </span> <span className="seval-chart-backtest-busy"> </span>
)} )}
</div> </div>
<span className="seval-chart-hint"> · · </span> <span className="seval-chart-hint"> · · · </span>
</div> </div>
<div className="btd-analysis-main"> <div className="btd-analysis-main">
<div className="seval-chart-body btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live"> <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>} {loading && <div className="seval-chart-status"> </div>}
{error && !loading && <div className="btd-analysis-error">{error}</div>} {error && !loading && <div className="btd-analysis-error">{error}</div>}
{backtestError && !loading && !backtestRunning && !error && ( {backtestError && !loading && !backtestRunning && !error && (
@@ -2,12 +2,9 @@ import React, { useMemo } from 'react';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { import {
buildConditionMetrics, buildConditionMetrics,
computeMatchRate,
getTrafficLightState,
type ConditionMetric, type ConditionMetric,
} from '../../utils/virtualSignalMetrics'; } from '../../utils/virtualSignalMetrics';
import VirtualConditionList from '../virtual/VirtualConditionList'; import VirtualSignalMatchVisual from '../virtual/VirtualSignalMatchVisual';
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals'; import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
interface Props { interface Props {
@@ -28,8 +25,6 @@ interface SideCardProps {
} }
const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalActive = false }) => { 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 title = side === 'buy' ? '매수 조건' : '매도 조건';
const overallLabel = const overallLabel =
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null; overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
@@ -41,7 +36,7 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
`seval-side-card--${side}`, `seval-side-card--${side}`,
signalActive ? 'seval-side-card--signal-active' : '', signalActive ? 'seval-side-card--signal-active' : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
aria-label={`${title} 지표 목록${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`} aria-label={`${title} 지표 일치율${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
> >
<header className="seval-side-card-head"> <header className="seval-side-card-head">
<div className="seval-side-card-head-main"> <div className="seval-side-card-head-main">
@@ -55,12 +50,13 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
</span> </span>
)} )}
</div> </div>
<div className="seval-side-card-head-signal">
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
</header> </header>
<div className="seval-side-card-body"> <div className="seval-side-card-body">
<VirtualConditionList metrics={metrics} /> <VirtualSignalMatchVisual
metrics={metrics}
title="SIGNAL INTELLIGENCE & MATCH RATES"
panelClassName="seval-side-sig-panel"
/>
</div> </div>
</section> </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 React, { useMemo } from 'react';
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
buildConditionMetrics, import VirtualSignalMatchVisual from './VirtualSignalMatchVisual';
computeMatchRate,
getTrafficLightState,
} from '../../utils/virtualSignalMetrics';
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
import VirtualConditionList from './VirtualConditionList';
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable'; import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
@@ -38,17 +31,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
const isDetail = viewMode === 'detail'; const isDetail = viewMode === 'detail';
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); 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) { if (rows.length === 0) {
return ( return (
@@ -71,22 +53,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
className, className,
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
> >
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}> <VirtualSignalMatchVisual
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div> metrics={metrics}
<div className="vtd-sig-visual" ref={visualRef}> title="SIGNAL INTELLIGENCE & MATCH RATES"
<div className="vtd-sig-visual-pane vtd-sig-visual-eq"> backendMatchRate={snapshot?.matchRate}
<VirtualSignalEqualizer matchRate={matchRate} /> panelClassName={isDetail ? 'vtd-sig-panel--detail-top' : 'vtd-sig-panel--summary'}
</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>
{isDetail && ( {isDetail && (
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록"> <div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} /> <VirtualIndicatorCompareTable metrics={metrics} />
+115 -85
View File
@@ -104,6 +104,35 @@
min-height: 0; 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 { .seval-chart-toolbar {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -341,40 +370,12 @@
} }
.seval-signal-body { .seval-signal-body {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; 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 { .seval-signal-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -384,12 +385,6 @@
padding: 0 2px; padding: 0 2px;
} }
.seval-signal-panel--loading {
opacity: 0.42;
pointer-events: none;
user-select: none;
}
.seval-eval-error { .seval-eval-error {
margin: 0 4px 8px; margin: 0 4px 8px;
font-size: 0.72rem; font-size: 0.72rem;
@@ -430,7 +425,8 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
padding: 4px 3px;
} }
.seval-side-card { .seval-side-card {
@@ -455,10 +451,10 @@
.seval-side-card-head { .seval-side-card-head {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 8px 10px 6px; padding: 6px 10px 4px;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); 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); 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 { .seval-side-card-body {
flex: 1; flex: 1;
min-height: 0; 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; overflow-y: auto;
} }
.seval-side-card-body .vtd-heat-list { .seval-side-card-body .vtd-sig-visual-eq {
max-height: none; 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 { .seval-side-card-body .vtd-heat-list-empty {
@@ -550,23 +562,45 @@
text-align: center; text-align: center;
} }
/* 선택 봉에 백테스트 매매 시그널이 있을 때 — 형광연두 그라데이션 아웃라인·음영 */ /* 선택 봉에 백테스트 매매 시그널 — 형광연두 아웃라인 + 외곽 glow (첨부 이미지 스타일) */
.seval-side-card--signal-active { .seval-side-card--signal-active {
position: relative; position: relative;
z-index: 1; z-index: 1;
border: 2px solid transparent; border-color: transparent;
background: isolation: isolate;
linear-gradient( overflow: visible;
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)) .seval-side-card--signal-active::before {
) padding-box, content: '';
linear-gradient(145deg, #e8ff00 0%, #39ff14 38%, #c6ff00 62%, #7fff00 100%) border-box; 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: box-shadow:
0 0 0 1px color-mix(in srgb, #ccff00 30%, transparent), 0 0 6px 1px rgba(184, 255, 0, 0.85),
0 0 14px color-mix(in srgb, #ccff00 42%, transparent), 0 0 14px 3px rgba(57, 255, 20, 0.55),
0 0 28px color-mix(in srgb, #39ff14 22%, transparent), 0 0 28px 5px rgba(204, 255, 0, 0.32);
inset 0 0 32px color-mix(in srgb, #ccff00 11%, transparent);
} }
.seval-side-card--signal-active.seval-side-card--buy, .seval-side-card--signal-active.seval-side-card--buy,
@@ -574,10 +608,6 @@
border-top-color: transparent; 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 { .seval-side-card--signal-active .seval-side-card-badge::after {
content: ' · 시그널'; content: ' · 시그널';
font-size: 0.62rem; font-size: 0.62rem;
@@ -1,4 +1,5 @@
import type { BacktestSignal } from './backendApi'; import type { BacktestSignal } from './backendApi';
import { normalizeEpochSecOptional } from './backtestUiUtils';
export interface BarSignalHighlight { export interface BarSignalHighlight {
buy: boolean; buy: boolean;
@@ -11,15 +12,39 @@ const isBuySignalType = (type: BacktestSignal['type']): boolean =>
const isSellSignalType = (type: BacktestSignal['type']): boolean => const isSellSignalType = (type: BacktestSignal['type']): boolean =>
type === 'SELL' || type === 'SHORT_ENTRY' || type === 'PARTIAL_SELL'; 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( export function resolveBarSignalHighlight(
signals: BacktestSignal[], signals: BacktestSignal[],
barTimeSec: number | null | undefined, barTimeSec: number | null | undefined,
barIndex?: number | null,
): BarSignalHighlight { ): BarSignalHighlight {
if (barTimeSec == null || signals.length === 0) { if (signals.length === 0) {
return { buy: false, sell: false }; 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 { return {
buy: atBar.some(s => isBuySignalType(s.type)), buy: atBar.some(s => isBuySignalType(s.type)),
sell: atBar.some(s => isSellSignalType(s.type)), sell: atBar.some(s => isSellSignalType(s.type)),