전략평가 화면 수정
This commit is contained in:
@@ -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 & 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} />
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user