diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 2a644fa..de5d3ce 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -7,17 +7,18 @@ import '../styles/virtualTradingDashboard.css'; import '../styles/strategyEditorTheme.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme, Timeframe } from '../types'; +import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; import { loadStrategies, loadStrategy, + type BacktestSignal, type StrategyDto, } from '../utils/backendApi'; import { hydrateStrategyDto } from '../utils/strategyHydrate'; import { repairUtf8Mojibake } from '../utils/textEncoding'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot'; -import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; -import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel'; +import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel'; import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; @@ -34,6 +35,7 @@ import { resolveBacktestExecTimeframe, type BacktestRunTimeframeChoice, } from '../utils/backtestRunTimeframe'; +import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals'; import type { OHLCVBar } from '../types'; const LEFT_KEY = 'seval-left-width'; @@ -71,6 +73,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [appliedIndicatorParams, setAppliedIndicatorParams] = useState(null); const [paramsRevision, setParamsRevision] = useState(0); const [chartLoading, setChartLoading] = useState(false); + const [backtestSignals, setBacktestSignals] = useState([]); const evalSessionRef = useRef(0); const paramsRevisionRef = useRef(paramsRevision); @@ -189,6 +192,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; + const barSignalHighlight = useMemo( + () => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec), + [backtestSignals, selectedBarTimeSec], + ); + + const handleSignalsChange = useCallback((signals: BacktestSignal[]) => { + setBacktestSignals(signals); + }, []); + const refreshEvaluation = useCallback(async () => { if (!selectedStrategyId || !selectedStrategy) { setSnapshot(undefined); @@ -410,6 +422,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { getParamsOverride={getEvalParams} getVisualConfigOverride={getVisualConfig} evalLoading={evalLoading} + onSignalsChange={handleSignalsChange} /> @@ -443,9 +456,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { 전략 조건 확인 중… )} - void; } const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; @@ -89,6 +91,7 @@ const StrategyEvaluationChart: React.FC = ({ getParamsOverride, getVisualConfigOverride, evalLoading = false, + onSignalsChange, }) => { const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings(); const getParams = getParamsOverride ?? baseGetParams; @@ -217,6 +220,10 @@ const StrategyEvaluationChart: React.FC = ({ applyMarkers(); }, [signals, applyMarkers]); + useEffect(() => { + onSignalsChange?.(signals); + }, [signals, onSignalsChange]); + useEffect(() => { onChartLoadingChange?.(loading); }, [loading, onChartLoadingChange]); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx new file mode 100644 index 0000000..6d44986 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -0,0 +1,114 @@ +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 type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals'; + +interface Props { + snapshot: VirtualIndicatorSnapshot | undefined; + /** 선택 봉에 백테스트 매수/매도 시그널 존재 여부 */ + barSignalHighlight?: BarSignalHighlight; + loading?: boolean; + loadingMessage?: string; + hasStrategy?: boolean; + className?: string; +} + +interface SideCardProps { + side: 'buy' | 'sell'; + metrics: ConditionMetric[]; + overallMet?: boolean | null; + signalActive?: boolean; +} + +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; + + return ( +
+
+
+ {title} + {overallLabel && ( + + {overallLabel} + + )} +
+
+ +
+
+
+ +
+
+ ); +}; + +const StrategyEvaluationSignalPanel: React.FC = ({ + snapshot, + barSignalHighlight, + loading = false, + loadingMessage, + hasStrategy = true, + className = '', +}) => { + const rows = snapshot?.rows ?? []; + const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); + const buyMetrics = useMemo(() => metrics.filter(m => m.row.side === 'buy'), [metrics]); + const sellMetrics = useMemo(() => metrics.filter(m => m.row.side === 'sell'), [metrics]); + + if (rows.length === 0) { + return ( +

+ {loading + ? (loadingMessage ?? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)') + : !hasStrategy + ? '투자전략을 선택하세요 (상단 또는 종목별 전략)' + : '전략 조건·지표 데이터 없음'} +

+ ); + } + + return ( +
+ + +
+ ); +}; + +export default StrategyEvaluationSignalPanel; diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 16630f3..3191187 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -33,6 +33,10 @@ export interface VirtualIndicatorSnapshot { updatedAt: number; /** 백엔드 Ta4j 집계 일치율 (0~100) */ matchRate?: number; + /** 매수 DSL 전체 Rule 충족 (AND/OR 반영) */ + overallEntryMet?: boolean | null; + /** 매도 DSL 전체 Rule 충족 (AND/OR 반영) */ + overallExitMet?: boolean | null; } export interface VirtualIndicatorSnapshotsState { diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 236219f..5d7faa7 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -378,7 +378,9 @@ .seval-signal-panel { flex: 1; min-height: 0; - overflow-y: auto; + overflow: hidden; + display: flex; + flex-direction: column; padding: 0 2px; } @@ -422,6 +424,168 @@ height: auto; } +/* ── 매수/매도 분리 시그널 패널 ─────────────────────────────────────────── */ +.seval-signal-split { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.seval-side-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 32%, var(--se-border, var(--btd-divider))); + border-radius: 10px; + background: var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent)); + overflow: hidden; +} + +.seval-side-card--buy { + border-top-color: color-mix(in srgb, #3fb950 55%, var(--se-border)); +} + +.seval-side-card--sell { + border-top-color: color-mix(in srgb, #f85149 55%, var(--se-border)); +} + +.seval-side-card-head { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 8px 10px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); +} + +.seval-side-card-head-main { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.seval-side-card-badge { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.04em; +} + +.seval-side-card-badge--buy { + color: #3fb950; +} + +.seval-side-card-badge--sell { + color: #f85149; +} + +.seval-side-overall { + display: inline-flex; + align-self: flex-start; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 700; + line-height: 1.3; +} + +.seval-side-overall--met { + color: #3fb950; + background: color-mix(in srgb, #3fb950 14%, transparent); + border: 1px solid color-mix(in srgb, #3fb950 35%, transparent); +} + +.seval-side-overall--unmet { + color: var(--se-text-muted); + background: color-mix(in srgb, var(--se-text-muted) 10%, 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 { + flex: 1; + min-height: 0; + padding: 4px 8px 8px; + overflow-y: auto; +} + +.seval-side-card-body .vtd-heat-list { + max-height: none; +} + +.seval-side-card-body .vtd-heat-list-empty { + padding: 12px 4px; + font-size: 0.72rem; + text-align: center; +} + +/* 선택 봉에 백테스트 매매 시그널이 있을 때 — 형광연두 그라데이션 아웃라인·음영 */ +.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; + 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); +} + +.seval-side-card--signal-active.seval-side-card--buy, +.seval-side-card--signal-active.seval-side-card--sell { + 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; + font-weight: 700; + color: #ccff00; + letter-spacing: 0.02em; +} + /* ── 전략설정 탭 ─────────────────────────────────────────────────────────── */ .seval-settings-tab { flex: 1; diff --git a/frontend/src/utils/strategyEvaluationBarSignals.ts b/frontend/src/utils/strategyEvaluationBarSignals.ts new file mode 100644 index 0000000..8027850 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationBarSignals.ts @@ -0,0 +1,27 @@ +import type { BacktestSignal } from './backendApi'; + +export interface BarSignalHighlight { + buy: boolean; + sell: boolean; +} + +const isBuySignalType = (type: BacktestSignal['type']): boolean => + type === 'BUY' || type === 'SHORT_EXIT'; + +const isSellSignalType = (type: BacktestSignal['type']): boolean => + type === 'SELL' || type === 'SHORT_ENTRY' || type === 'PARTIAL_SELL'; + +/** 선택 봉 시각에 백테스트 매매 시그널이 있는지 (차트 마커와 동일 기준) */ +export function resolveBarSignalHighlight( + signals: BacktestSignal[], + barTimeSec: number | null | undefined, +): BarSignalHighlight { + if (barTimeSec == null || signals.length === 0) { + return { buy: false, sell: false }; + } + const atBar = signals.filter(s => s.time === barTimeSec); + return { + buy: atBar.some(s => isBuySignalType(s.type)), + sell: atBar.some(s => isSellSignalType(s.type)), + }; +} diff --git a/frontend/src/utils/strategyEvaluationSnapshot.ts b/frontend/src/utils/strategyEvaluationSnapshot.ts index 3d3eac1..29ac5f0 100644 --- a/frontend/src/utils/strategyEvaluationSnapshot.ts +++ b/frontend/src/utils/strategyEvaluationSnapshot.ts @@ -51,6 +51,8 @@ export async function fetchEvaluationSnapshotAtBar( rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), updatedAt: Date.now(), matchRate: 0, + overallEntryMet: null, + overallExitMet: null, }; } @@ -62,5 +64,7 @@ export async function fetchEvaluationSnapshotAtBar( rows, updatedAt: status.updatedAt || Date.now(), matchRate: coerceFiniteNumber(status.matchRate) ?? 0, + overallEntryMet: status.overallEntryMet ?? null, + overallExitMet: status.overallExitMet ?? null, }; }