= ({
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)),