diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
index 0f14514..3c13336 100644
--- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
+++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
@@ -1050,8 +1050,7 @@ public class StrategyDslToTa4jAdapter {
if (cond == null || cond.isNull()) return "";
int candleRange = cond.path("candleRange").asInt(1);
String mode = cond.path("candleRangeMode").asText("");
- if (mode.isBlank()) mode = candleRange <= 1 ? "CURRENT" : "EXISTS_IN";
- if ("CURRENT".equals(mode)) return "";
+ if (mode.isBlank()) return "";
int n = Math.max(2, candleRange);
return switch (mode) {
case "EXISTS_IN" -> "최근 " + n + "봉 내 ";
@@ -1060,11 +1059,11 @@ public class StrategyDslToTa4jAdapter {
};
}
- /** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */
+ /** candleRangeMode 가 명시된 경우에만 윈도우 평가 (candleRange 숫자만으로는 적용하지 않음) */
private String resolveCandleRangeMode(JsonNode cond, int candleRange) {
String mode = cond.path("candleRangeMode").asText("");
- if (!mode.isBlank()) return mode;
- return candleRange <= 1 ? "CURRENT" : "EXISTS_IN";
+ if (mode.isBlank()) return "CURRENT";
+ return mode;
}
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
diff --git a/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java
index 6abb829..e89a708 100644
--- a/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java
+++ b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java
@@ -55,13 +55,17 @@ class CandleRangeWindowRuleTest {
}
@Test
- void legacyCandleRangeFour_treatedAsExistsIn() throws Exception {
+ void legacyCandleRangeWithoutMode_treatedAsCurrent() throws Exception {
Rule rule = toGtRule("""
"candleRange": 4
""");
+ Rule explicit = toGtRule("""
+ "candleRangeMode": "CURRENT",
+ "candleRange": 4
+ """);
int idx = series.getEndIndex();
- assertTrue(rule.isSatisfied(idx, null),
- "candleRangeMode 미설정 + candleRange=4 → EXISTS_IN 호환");
+ assertEquals(explicit.isSatisfied(idx, null), rule.isSatisfied(idx, null),
+ "candleRangeMode 미설정 + candleRange=4 → CURRENT (윈도우 미적용)");
}
@Test
diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx
index eeca78d..c246a9e 100644
--- a/frontend/src/components/StrategyEvaluationPage.tsx
+++ b/frontend/src/components/StrategyEvaluationPage.tsx
@@ -457,6 +457,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: 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 = ({
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 = ({
strategyName: strategy.name,
settings: btSettings,
indicatorParams,
- evaluationBarCount,
+ evaluationBarCount: evalCount,
});
if (gen !== backtestGenRef.current) return;
if (!res) {
@@ -239,9 +259,15 @@ const StrategyEvaluationChart: React.FC = ({
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 = ({
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 = ({
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 = ({
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 = ({
showHoverToolbar={false}
showChartRightToolbar={false}
showCandlePaneControls={false}
+ resolveInitialDisplayCount={resolveInitialDisplayCount}
/>
{chartMgr && (
void;
+}
+
+const StrategyEvaluationInterpretModal: React.FC = ({
+ 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 (
+
+
+
{intro}
+
+
+
+ {interpretation.overallMet === true
+ ? '전체 충족'
+ : interpretation.overallMet === false
+ ? '전체 미충족'
+ : '전체 평가 불명'}
+
+
{interpretation.overallSummary}
+
+
+ {interpretation.conditions.length === 0 ? (
+
표시할 조건이 없습니다.
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+export default StrategyEvaluationInterpretModal;
diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx
index 8b0230a..3d64be2 100644
--- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx
+++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx
@@ -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 = () => (
+
+);
+
interface SideCardProps {
side: 'buy' | 'sell';
metrics: ConditionMetric[];
overallMet?: boolean | null;
signalActive?: boolean;
+ strategy?: StrategyDto | null;
+ barTimeSec?: number | null;
}
-const SideCard: React.FC = ({ side, metrics, overallMet, signalActive = false }) => {
+const SideCard: React.FC = ({
+ 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 (
-