수정
This commit is contained in:
@@ -457,6 +457,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
<div className="seval-signal-body">
|
||||
<StrategyEvaluationSignalPanel
|
||||
snapshot={snapshot}
|
||||
strategy={selectedStrategy}
|
||||
barTimeSec={selectedBarTimeSec}
|
||||
barSignalHighlight={barSignalHighlight}
|
||||
hasStrategy={selectedStrategyId != null}
|
||||
className="seval-signal-panel"
|
||||
|
||||
@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom';
|
||||
import TradingChart from '../TradingChart';
|
||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
@@ -16,9 +15,14 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { loadBacktestSettings, runBacktest } from '../../utils/backendApi';
|
||||
import {
|
||||
BACKTEST_DISPLAY_BAR_COUNT,
|
||||
fetchBacktestCandleBundle,
|
||||
resolveEvaluationFromLoadedBars,
|
||||
} from '../../utils/backtestWarmup';
|
||||
import { DISPLAY_COUNT } from '../../hooks/useUpbitData';
|
||||
import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators';
|
||||
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
|
||||
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
|
||||
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||
import StrategyEvaluationBarSelector, {
|
||||
resolveBarIndexAtChartClick,
|
||||
@@ -136,6 +140,21 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
[strategy, getParams],
|
||||
);
|
||||
|
||||
const resolveInitialDisplayCount = useCallback(
|
||||
(plotWidth: number) => {
|
||||
const approx = Math.floor(plotWidth / 6);
|
||||
return Math.min(DISPLAY_COUNT, Math.max(80, approx), bars.length || DISPLAY_COUNT);
|
||||
},
|
||||
[bars.length],
|
||||
);
|
||||
|
||||
const fitChartViewport = useCallback(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr?.hasMainSeries() || bars.length < 2) return;
|
||||
const count = Math.min(DISPLAY_COUNT, bars.length);
|
||||
mgr.setInitialVisibleRange(count);
|
||||
}, [bars.length]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr?.hasMainSeries()) {
|
||||
@@ -167,6 +186,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
strategy.sellCondition,
|
||||
indicatorParams,
|
||||
);
|
||||
const evalCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, evaluationBarCount);
|
||||
const res = await runBacktest({
|
||||
strategyId: strategy.id,
|
||||
bars: barData.map(b => ({
|
||||
@@ -182,7 +202,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
strategyName: strategy.name,
|
||||
settings: btSettings,
|
||||
indicatorParams,
|
||||
evaluationBarCount,
|
||||
evaluationBarCount: evalCount,
|
||||
});
|
||||
if (gen !== backtestGenRef.current) return;
|
||||
if (!res) {
|
||||
@@ -239,9 +259,15 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
managerRef.current?.clearBacktestMarkers();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const toTimeSec = Math.floor(Date.now() / 1000);
|
||||
void loadAnalysisCandles(market, timeframe, toTimeSec, 300)
|
||||
.then(data => {
|
||||
void fetchBacktestCandleBundle({
|
||||
market,
|
||||
timeframe,
|
||||
displayCount: BACKTEST_DISPLAY_BAR_COUNT,
|
||||
buyDsl: strategy?.buyCondition,
|
||||
sellDsl: strategy?.sellCondition,
|
||||
indicatorParams,
|
||||
})
|
||||
.then(({ bars: data }) => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
onBarsLoaded?.(data);
|
||||
@@ -258,7 +284,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, strategy?.id, onBarsLoaded, onSelectedBarIndexChange]);
|
||||
}, [market, timeframe, strategy?.id, strategy?.buyCondition, strategy?.sellCondition, indicatorParams, onBarsLoaded, onSelectedBarIndexChange]);
|
||||
|
||||
const scrollToBar = useCallback((barIdx: number) => {
|
||||
const mgr = chartMgr;
|
||||
@@ -275,7 +301,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||
}
|
||||
applyMarkers();
|
||||
}, [paneAreaRatio, applyMarkers]);
|
||||
fitChartViewport();
|
||||
}, [paneAreaRatio, applyMarkers, fitChartViewport]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
const mgr = managerRef.current;
|
||||
@@ -284,7 +311,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||
}
|
||||
applyMarkers();
|
||||
}, [paneAreaRatio, applyMarkers]);
|
||||
fitChartViewport();
|
||||
}, [paneAreaRatio, applyMarkers, fitChartViewport]);
|
||||
|
||||
const stepBar = useCallback((delta: number) => {
|
||||
if (bars.length === 0) return;
|
||||
@@ -482,6 +510,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
showHoverToolbar={false}
|
||||
showChartRightToolbar={false}
|
||||
showCandlePaneControls={false}
|
||||
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
||||
/>
|
||||
{chartMgr && (
|
||||
<StrategyEvaluationBarSelector
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DraggableModalFrame from '../DraggableModalFrame';
|
||||
import type { SideInterpretation } from '../../utils/strategyEvaluationInterpret';
|
||||
import { formatBarTimeLabel } from '../../utils/strategyEvaluationInterpret';
|
||||
|
||||
interface Props {
|
||||
side: 'buy' | 'sell';
|
||||
interpretation: SideInterpretation;
|
||||
barTimeSec?: number | null;
|
||||
strategyName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StrategyEvaluationInterpretModal: React.FC<Props> = ({
|
||||
side,
|
||||
interpretation,
|
||||
barTimeSec,
|
||||
strategyName,
|
||||
onClose,
|
||||
}) => {
|
||||
const title = side === 'buy' ? '매수 조건 해석' : '매도 조건 해석';
|
||||
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 parts = [`${barLabel} 시점`, strategyName ? `「${strategyName}」` : null].filter(Boolean);
|
||||
return parts.join(' · ');
|
||||
}, [barLabel, strategyName]);
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
titleKo="Strategy Evaluation"
|
||||
badge={side === 'buy' ? 'BUY' : 'SELL'}
|
||||
width={520}
|
||||
overlayClassName="seval-interpret-overlay"
|
||||
dialogClassName="seval-interpret-modal app-popup-shell"
|
||||
bodyClassName="seval-interpret-body app-popup-body"
|
||||
zIndex={11000}
|
||||
>
|
||||
<div className="seval-interpret-content">
|
||||
<p className="seval-interpret-intro">{intro}</p>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<footer className="seval-interpret-footnote">
|
||||
※ 조건별 결과는 Ta4j Rule 평가값이며, AND/OR/NOT 전체 결합 결과는 상단 「전체 충족/미충족」을 참고하세요.
|
||||
</footer>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyEvaluationInterpretModal;
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
type ConditionMetric,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import {
|
||||
buildConditionContextMap,
|
||||
buildSideInterpretation,
|
||||
} from '../../utils/strategyEvaluationInterpret';
|
||||
import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual';
|
||||
import StrategyEvaluationInterpretModal from './StrategyEvaluationInterpretModal';
|
||||
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
|
||||
|
||||
interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
strategy?: StrategyDto | null;
|
||||
barTimeSec?: number | null;
|
||||
/** 선택 봉에 백테스트 매수/매도 시그널 존재 여부 */
|
||||
barSignalHighlight?: BarSignalHighlight;
|
||||
loading?: boolean;
|
||||
@@ -17,49 +25,99 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InterpretIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden className="seval-interpret-icon">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.75" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.25 10.5h1.5V17h-1.5V10.5zm0-3.25h1.5V9h-1.5V6.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SideCardProps {
|
||||
side: 'buy' | 'sell';
|
||||
metrics: ConditionMetric[];
|
||||
overallMet?: boolean | null;
|
||||
signalActive?: boolean;
|
||||
strategy?: StrategyDto | null;
|
||||
barTimeSec?: number | null;
|
||||
}
|
||||
|
||||
const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalActive = false }) => {
|
||||
const SideCard: React.FC<SideCardProps> = ({
|
||||
side,
|
||||
metrics,
|
||||
overallMet,
|
||||
signalActive = false,
|
||||
strategy,
|
||||
barTimeSec,
|
||||
}) => {
|
||||
const [interpretOpen, setInterpretOpen] = useState(false);
|
||||
const title = side === 'buy' ? '매수 조건' : '매도 조건';
|
||||
const overallLabel =
|
||||
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
|
||||
|
||||
const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]);
|
||||
const interpretation = useMemo(
|
||||
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
|
||||
[side, metrics, overallMet, contextMap],
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
<>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="seval-interpret-btn"
|
||||
title="전략 판별 해석 — 조건별 충족/미충족 이유"
|
||||
aria-label={`${title} 전략 판별 해석`}
|
||||
disabled={metrics.length === 0}
|
||||
onClick={() => setInterpretOpen(true)}
|
||||
>
|
||||
<InterpretIcon />
|
||||
</button>
|
||||
</header>
|
||||
<div className="seval-side-card-body">
|
||||
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="seval-side-card-body">
|
||||
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{interpretOpen && (
|
||||
<StrategyEvaluationInterpretModal
|
||||
side={side}
|
||||
interpretation={interpretation}
|
||||
barTimeSec={barTimeSec}
|
||||
strategyName={strategy?.name}
|
||||
onClose={() => setInterpretOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
snapshot,
|
||||
strategy,
|
||||
barTimeSec,
|
||||
barSignalHighlight,
|
||||
loading = false,
|
||||
loadingMessage,
|
||||
@@ -92,12 +150,16 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
metrics={sellMetrics}
|
||||
overallMet={snapshot?.overallExitMet}
|
||||
signalActive={barSignalHighlight?.sell ?? false}
|
||||
strategy={strategy}
|
||||
barTimeSec={barTimeSec}
|
||||
/>
|
||||
<SideCard
|
||||
side="buy"
|
||||
metrics={buyMetrics}
|
||||
overallMet={snapshot?.overallEntryMet}
|
||||
signalActive={barSignalHighlight?.buy ?? false}
|
||||
strategy={strategy}
|
||||
barTimeSec={barTimeSec}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user