전략평가 수정

This commit is contained in:
Macbook
2026-06-12 14:59:23 +09:00
parent c562a4eab2
commit 7579ed9de4
7 changed files with 338 additions and 5 deletions
@@ -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<EvalIndicatorParams | null>(null);
const [paramsRevision, setParamsRevision] = useState(0);
const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
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}
/>
</div>
</main>
@@ -443,9 +456,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
<span className="seval-eval-loading-text"> </span>
</div>
)}
<VirtualTargetSignalPanel
<StrategyEvaluationSignalPanel
snapshot={snapshot}
viewMode="summary"
barSignalHighlight={barSignalHighlight}
loading={evalLoading}
loadingMessage="전략 조건 확인 중…"
hasStrategy={selectedStrategyId != null}
@@ -58,6 +58,8 @@ interface Props {
};
/** 선택 봉 조건 평가 진행 중 */
evalLoading?: boolean;
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
onSignalsChange?: (signals: BacktestSignal[]) => void;
}
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
@@ -89,6 +91,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
getParamsOverride,
getVisualConfigOverride,
evalLoading = false,
onSignalsChange,
}) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams;
@@ -217,6 +220,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
applyMarkers();
}, [signals, applyMarkers]);
useEffect(() => {
onSignalsChange?.(signals);
}, [signals, onSignalsChange]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
@@ -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<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 overallLabel =
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
return (
<section
className={[
'seval-side-card',
`seval-side-card--${side}`,
signalActive ? 'seval-side-card--signal-active' : '',
].filter(Boolean).join(' ')}
aria-label={`${title} 지표 목록${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
>
<header className="seval-side-card-head">
<div className="seval-side-card-head-main">
<span className={`seval-side-card-badge seval-side-card-badge--${side}`}>{title}</span>
{overallLabel && (
<span
className={`seval-side-overall seval-side-overall--${overallMet ? 'met' : 'unmet'}`}
title="DSL 전체 Rule 충족 여부 (AND/OR 반영)"
>
{overallLabel}
</span>
)}
</div>
<div className="seval-side-card-head-signal">
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
</header>
<div className="seval-side-card-body">
<VirtualConditionList metrics={metrics} />
</div>
</section>
);
};
const StrategyEvaluationSignalPanel: React.FC<Props> = ({
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 (
<p
className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}
>
{loading
? (loadingMessage ?? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)')
: !hasStrategy
? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
: '전략 조건·지표 데이터 없음'}
</p>
);
}
return (
<div className={['seval-signal-split', className].filter(Boolean).join(' ')}>
<SideCard
side="buy"
metrics={buyMetrics}
overallMet={snapshot?.overallEntryMet}
signalActive={barSignalHighlight?.buy ?? false}
/>
<SideCard
side="sell"
metrics={sellMetrics}
overallMet={snapshot?.overallExitMet}
signalActive={barSignalHighlight?.sell ?? false}
/>
</div>
);
};
export default StrategyEvaluationSignalPanel;
@@ -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 {
+165 -1
View File
@@ -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;
@@ -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)),
};
}
@@ -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,
};
}