전략평가 상세설명 표시

This commit is contained in:
Macbook
2026-06-16 23:56:17 +09:00
parent e4611404a9
commit a6e889a579
5 changed files with 294 additions and 136 deletions
@@ -0,0 +1,71 @@
import React, { useMemo } from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import {
buildConditionContextMap,
buildSideInterpretation,
formatBarTimeLabel,
} from '../../utils/strategyEvaluationInterpret';
import StrategyEvaluationInterpretContent from './StrategyEvaluationInterpretContent';
interface Props {
buyMetrics: ConditionMetric[];
sellMetrics: ConditionMetric[];
buyOverallMet?: boolean | null;
sellOverallMet?: boolean | null;
strategy?: StrategyDto | null;
barTimeSec?: number | null;
getParams?: (
type: string,
defaults?: Record<string, number | string | boolean>,
) => Record<string, number | string | boolean>;
}
const StrategyEvaluationDetailPanel: React.FC<Props> = ({
buyMetrics,
sellMetrics,
buyOverallMet,
sellOverallMet,
strategy,
barTimeSec,
getParams,
}) => {
const contextMap = useMemo(
() => buildConditionContextMap(strategy, getParams),
[strategy, getParams],
);
const buyInterpretation = useMemo(
() => buildSideInterpretation('buy', buyMetrics, buyOverallMet, contextMap),
[buyMetrics, buyOverallMet, contextMap],
);
const sellInterpretation = useMemo(
() => buildSideInterpretation('sell', sellMetrics, sellOverallMet, contextMap),
[sellMetrics, sellOverallMet, contextMap],
);
const barLabel = formatBarTimeLabel(barTimeSec);
return (
<section className="seval-detail-panel" aria-label="매수·매도 상세 분석">
<header className="seval-detail-panel-head">
<span className="seval-detail-panel-title"> </span>
<span className="seval-detail-panel-meta">{barLabel}</span>
</header>
<div className="seval-detail-panel-body">
<div className="seval-detail-side seval-detail-side--buy">
<h3 className="seval-detail-side-label"></h3>
<StrategyEvaluationInterpretContent interpretation={buyInterpretation} compact />
</div>
<div className="seval-detail-side seval-detail-side--sell">
<h3 className="seval-detail-side-label"></h3>
<StrategyEvaluationInterpretContent interpretation={sellInterpretation} compact />
</div>
<footer className="seval-detail-footnote">
Ta4j Rule , AND/OR/NOT / .
</footer>
</div>
</section>
);
};
export default StrategyEvaluationDetailPanel;
@@ -0,0 +1,59 @@
import React from 'react';
import type { SideInterpretation } from '../../utils/strategyEvaluationInterpret';
interface Props {
interpretation: SideInterpretation;
compact?: boolean;
}
/** 매수/매도 조건 상세 해석 본문 (모달·우측 패널 공용) */
const StrategyEvaluationInterpretContent: React.FC<Props> = ({
interpretation,
compact = false,
}) => {
const overallClass = interpretation.overallMet === true
? 'seval-interpret-overall--met'
: interpretation.overallMet === false
? 'seval-interpret-overall--unmet'
: 'seval-interpret-overall--unknown';
return (
<div className={`seval-interpret-content${compact ? ' seval-interpret-content--compact' : ''}`}>
<div className={`seval-interpret-overall ${overallClass}`}>
<span className="seval-interpret-overall-badge">
{interpretation.overallMet === true
? '전체 충족'
: interpretation.overallMet === false
? '전체 미충족'
: '전체 평가 불명'}
</span>
<p className="seval-interpret-overall-text">{interpretation.overallSummary}</p>
</div>
{interpretation.conditions.length === 0 ? (
<p className="seval-interpret-empty"> .</p>
) : (
<ul className="seval-interpret-list">
{interpretation.conditions.map(item => (
<li
key={item.id}
className={`seval-interpret-item seval-interpret-item--${item.status}`}
>
<div className="seval-interpret-item-head">
<span className={`seval-interpret-status seval-interpret-status--${item.status}`}>
{item.statusLabel}
</span>
<span className="seval-interpret-item-title" title={item.summary}>
{item.summary || item.label}
</span>
</div>
<p className="seval-interpret-reason">{item.reason}</p>
</li>
))}
</ul>
)}
</div>
);
};
export default StrategyEvaluationInterpretContent;
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import DraggableModalFrame from '../DraggableModalFrame'; import DraggableModalFrame from '../DraggableModalFrame';
import type { SideInterpretation } from '../../utils/strategyEvaluationInterpret'; import type { SideInterpretation } from '../../utils/strategyEvaluationInterpret';
import { formatBarTimeLabel } from '../../utils/strategyEvaluationInterpret'; import { formatBarTimeLabel } from '../../utils/strategyEvaluationInterpret';
import StrategyEvaluationInterpretContent from './StrategyEvaluationInterpretContent';
interface Props { interface Props {
side: 'buy' | 'sell'; side: 'buy' | 'sell';
@@ -21,12 +22,6 @@ const StrategyEvaluationInterpretModal: React.FC<Props> = ({
const title = side === 'buy' ? '매수 조건 해석' : '매도 조건 해석'; const title = side === 'buy' ? '매수 조건 해석' : '매도 조건 해석';
const barLabel = formatBarTimeLabel(barTimeSec); const barLabel = formatBarTimeLabel(barTimeSec);
const overallClass = interpretation.overallMet === true
? 'seval-interpret-overall--met'
: interpretation.overallMet === false
? 'seval-interpret-overall--unmet'
: 'seval-interpret-overall--unknown';
const intro = useMemo(() => { const intro = useMemo(() => {
const parts = [`${barLabel} 시점`, strategyName ? `${strategyName}` : null].filter(Boolean); const parts = [`${barLabel} 시점`, strategyName ? `${strategyName}` : null].filter(Boolean);
return parts.join(' · '); return parts.join(' · ');
@@ -44,47 +39,11 @@ const StrategyEvaluationInterpretModal: React.FC<Props> = ({
bodyClassName="seval-interpret-body app-popup-body" bodyClassName="seval-interpret-body app-popup-body"
zIndex={11000} zIndex={11000}
> >
<div className="seval-interpret-content"> <p className="seval-interpret-intro">{intro}</p>
<p className="seval-interpret-intro">{intro}</p> <StrategyEvaluationInterpretContent interpretation={interpretation} />
<footer className="seval-interpret-footnote">
<div className={`seval-interpret-overall ${overallClass}`}> Ta4j Rule , AND/OR/NOT / .
<span className="seval-interpret-overall-badge"> </footer>
{interpretation.overallMet === true
? '전체 충족'
: interpretation.overallMet === false
? '전체 미충족'
: '전체 평가 불명'}
</span>
<p className="seval-interpret-overall-text">{interpretation.overallSummary}</p>
</div>
{interpretation.conditions.length === 0 ? (
<p className="seval-interpret-empty"> .</p>
) : (
<ul className="seval-interpret-list">
{interpretation.conditions.map(item => (
<li
key={item.id}
className={`seval-interpret-item seval-interpret-item--${item.status}`}
>
<div className="seval-interpret-item-head">
<span className={`seval-interpret-status seval-interpret-status--${item.status}`}>
{item.statusLabel}
</span>
<span className="seval-interpret-item-title" title={item.summary}>
{item.summary || item.label}
</span>
</div>
<p className="seval-interpret-reason">{item.reason}</p>
</li>
))}
</ul>
)}
<footer className="seval-interpret-footnote">
Ta4j Rule , AND/OR/NOT / .
</footer>
</div>
</DraggableModalFrame> </DraggableModalFrame>
); );
}; };
@@ -1,16 +1,9 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
buildConditionMetrics,
type ConditionMetric,
} from '../../utils/virtualSignalMetrics';
import {
buildConditionContextMap,
buildSideInterpretation,
} from '../../utils/strategyEvaluationInterpret';
import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual'; import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual';
import StrategyEvaluationInterpretModal from './StrategyEvaluationInterpretModal'; import StrategyEvaluationDetailPanel from './StrategyEvaluationDetailPanel';
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals'; import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
interface Props { interface Props {
@@ -29,12 +22,9 @@ interface Props {
interface SideCardProps { interface SideCardProps {
side: 'buy' | 'sell'; side: 'buy' | 'sell';
metrics: ConditionMetric[]; metrics: ReturnType<typeof buildConditionMetrics>;
overallMet?: boolean | null; overallMet?: boolean | null;
signalActive?: boolean; signalActive?: boolean;
strategy?: StrategyDto | null;
barTimeSec?: number | null;
getParams?: Props['getParams'];
} }
const SideCard: React.FC<SideCardProps> = ({ const SideCard: React.FC<SideCardProps> = ({
@@ -42,70 +32,38 @@ const SideCard: React.FC<SideCardProps> = ({
metrics, metrics,
overallMet, overallMet,
signalActive = false, signalActive = false,
strategy,
barTimeSec,
getParams,
}) => { }) => {
const [interpretOpen, setInterpretOpen] = useState(false);
const sideLabel = side === 'buy' ? '매수' : '매도'; const sideLabel = side === 'buy' ? '매수' : '매도';
const title = side === 'buy' ? '매수 조건' : '매도 조건'; const title = side === 'buy' ? '매수 조건' : '매도 조건';
const overallLabel = const overallLabel =
overallMet === true ? '충족' : overallMet === false ? '미충족' : null; overallMet === true ? '충족' : overallMet === false ? '미충족' : null;
const contextMap = useMemo(() => buildConditionContextMap(strategy, getParams), [strategy, getParams]);
const interpretation = useMemo(
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
[side, metrics, overallMet, contextMap],
);
return ( return (
<> <section
<section className={[
className={[ 'seval-side-card',
'seval-side-card', `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"> <span className={`seval-side-card-badge seval-side-card-badge--${side}`}>{sideLabel}</span>
<span className={`seval-side-card-badge seval-side-card-badge--${side}`}>{sideLabel}</span> {overallLabel && (
{overallLabel && ( <span
<span className={`seval-side-overall seval-side-overall--${overallMet ? 'met' : 'unmet'}`}
className={`seval-side-overall seval-side-overall--${overallMet ? 'met' : 'unmet'}`} title="DSL 전체 Rule 충족 여부 (AND/OR 반영)"
title="DSL 전체 Rule 충족 여부 (AND/OR 반영)" >
> {overallLabel}
{overallLabel} </span>
</span> )}
)}
</div>
<button
type="button"
className="seval-analyze-btn"
title="조건별 충족/미충족 분석"
aria-label={`${title} 분석보기`}
disabled={metrics.length === 0}
onClick={() => setInterpretOpen(true)}
>
</button>
</header>
<div className="seval-side-card-body">
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
</div> </div>
</section> </header>
<div className="seval-side-card-body">
{interpretOpen && ( <StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
<StrategyEvaluationInterpretModal </div>
side={side} </section>
interpretation={interpretation}
barTimeSec={barTimeSec}
strategyName={strategy?.name}
onClose={() => setInterpretOpen(false)}
/>
)}
</>
); );
}; };
@@ -141,20 +99,23 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
return ( return (
<div className={['seval-signal-split', className].filter(Boolean).join(' ')}> <div className={['seval-signal-split', className].filter(Boolean).join(' ')}>
<SideCard
side="sell"
metrics={sellMetrics}
overallMet={snapshot?.overallExitMet}
signalActive={barSignalHighlight?.sell ?? false}
strategy={strategy}
barTimeSec={barTimeSec}
getParams={getParams}
/>
<SideCard <SideCard
side="buy" side="buy"
metrics={buyMetrics} metrics={buyMetrics}
overallMet={snapshot?.overallEntryMet} overallMet={snapshot?.overallEntryMet}
signalActive={barSignalHighlight?.buy ?? false} signalActive={barSignalHighlight?.buy ?? false}
/>
<SideCard
side="sell"
metrics={sellMetrics}
overallMet={snapshot?.overallExitMet}
signalActive={barSignalHighlight?.sell ?? false}
/>
<StrategyEvaluationDetailPanel
buyMetrics={buyMetrics}
sellMetrics={sellMetrics}
buyOverallMet={snapshot?.overallEntryMet}
sellOverallMet={snapshot?.overallExitMet}
strategy={strategy} strategy={strategy}
barTimeSec={barTimeSec} barTimeSec={barTimeSec}
getParams={getParams} getParams={getParams}
+118 -10
View File
@@ -675,18 +675,18 @@
height: auto; height: auto;
} }
/* ── 매수/매도 분리 시그널 패널 ─────────────────────────────────────────── */ /* ── 매수/매도 분리 시그널 패널 (3단: 매수 상태 · 매도 상태 · 상세 분석) ─ */
.seval-signal-split { .seval-signal-split {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px; gap: 8px;
padding: 4px 3px; padding: 4px 3px;
} }
.seval-side-card { .seval-side-card {
flex: 1; flex-shrink: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -778,8 +778,7 @@
} }
.seval-side-card-body { .seval-side-card-body {
flex: 1; flex-shrink: 0;
min-height: 0;
padding: 0 6px 6px; padding: 0 6px 6px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@@ -788,8 +787,7 @@
/* ── 전략평가 전용 매치 비주얼 (vtd-heat-* 미사용) ─────────────────────────── */ /* ── 전략평가 전용 매치 비주얼 (vtd-heat-* 미사용) ─────────────────────────── */
.seval-match-visual { .seval-match-visual {
flex: 1; flex-shrink: 0;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -808,7 +806,7 @@
/* 이퀄라이저 | 지표 목록 | 신호등 — 3열 (가상매매 vtd-sig-visual 동일) */ /* 이퀄라이저 | 지표 목록 | 신호등 — 3열 (가상매매 vtd-sig-visual 동일) */
.seval-match-visual-row { .seval-match-visual-row {
--seval-gauge-rail-height: 108px; --seval-gauge-rail-height: 88px;
flex-shrink: 0; flex-shrink: 0;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
@@ -822,6 +820,116 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ── 3단 — 상세 분석 패널 ─────────────────────────────────────────────────── */
.seval-detail-panel {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 28%, var(--se-border));
border-radius: 10px;
background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 85%, transparent);
overflow: hidden;
}
.seval-detail-panel-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent);
}
.seval-detail-panel-title {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--se-gold, #e6c200);
}
.seval-detail-panel-meta {
font-size: 0.64rem;
font-weight: 600;
color: var(--se-text-muted);
}
.seval-detail-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.seval-detail-side {
flex-shrink: 0;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--se-border, var(--btd-divider));
background: color-mix(in srgb, var(--se-input-bg, #0d111f) 60%, transparent);
}
.seval-detail-side--buy {
border-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 30%, var(--se-border));
background: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 5%, var(--vtd-sig-panel-bg, #0d111f));
}
.seval-detail-side--sell {
border-color: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 30%, var(--se-border));
background: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 5%, var(--vtd-sig-panel-bg, #0d111f));
}
.seval-detail-side-label {
margin: 0 0 8px;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.05em;
}
.seval-detail-side--buy .seval-detail-side-label {
color: var(--gc-trade-buy, #ef5350);
}
.seval-detail-side--sell .seval-detail-side-label {
color: var(--gc-trade-sell, #4dabf7);
}
.seval-detail-footnote {
flex-shrink: 0;
margin: 0;
padding-top: 4px;
font-size: 0.62rem;
line-height: 1.5;
color: var(--se-text-muted);
}
.seval-interpret-content--compact .seval-interpret-overall {
margin-bottom: 10px;
padding: 8px 10px;
}
.seval-interpret-content--compact .seval-interpret-overall-text {
font-size: 0.76rem;
line-height: 1.55;
}
.seval-interpret-content--compact .seval-interpret-list {
gap: 6px;
}
.seval-interpret-content--compact .seval-interpret-item {
padding: 8px 10px;
}
.seval-interpret-content--compact .seval-interpret-reason {
font-size: 0.72rem;
line-height: 1.5;
}
.seval-match-pane { .seval-match-pane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;