수정
This commit is contained in:
@@ -1050,8 +1050,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (cond == null || cond.isNull()) return "";
|
if (cond == null || cond.isNull()) return "";
|
||||||
int candleRange = cond.path("candleRange").asInt(1);
|
int candleRange = cond.path("candleRange").asInt(1);
|
||||||
String mode = cond.path("candleRangeMode").asText("");
|
String mode = cond.path("candleRangeMode").asText("");
|
||||||
if (mode.isBlank()) mode = candleRange <= 1 ? "CURRENT" : "EXISTS_IN";
|
if (mode.isBlank()) return "";
|
||||||
if ("CURRENT".equals(mode)) return "";
|
|
||||||
int n = Math.max(2, candleRange);
|
int n = Math.max(2, candleRange);
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case "EXISTS_IN" -> "최근 " + n + "봉 내 ";
|
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) {
|
private String resolveCandleRangeMode(JsonNode cond, int candleRange) {
|
||||||
String mode = cond.path("candleRangeMode").asText("");
|
String mode = cond.path("candleRangeMode").asText("");
|
||||||
if (!mode.isBlank()) return mode;
|
if (mode.isBlank()) return "CURRENT";
|
||||||
return candleRange <= 1 ? "CURRENT" : "EXISTS_IN";
|
return mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
|
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
|
||||||
|
|||||||
@@ -55,13 +55,17 @@ class CandleRangeWindowRuleTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void legacyCandleRangeFour_treatedAsExistsIn() throws Exception {
|
void legacyCandleRangeWithoutMode_treatedAsCurrent() throws Exception {
|
||||||
Rule rule = toGtRule("""
|
Rule rule = toGtRule("""
|
||||||
"candleRange": 4
|
"candleRange": 4
|
||||||
""");
|
""");
|
||||||
|
Rule explicit = toGtRule("""
|
||||||
|
"candleRangeMode": "CURRENT",
|
||||||
|
"candleRange": 4
|
||||||
|
""");
|
||||||
int idx = series.getEndIndex();
|
int idx = series.getEndIndex();
|
||||||
assertTrue(rule.isSatisfied(idx, null),
|
assertEquals(explicit.isSatisfied(idx, null), rule.isSatisfied(idx, null),
|
||||||
"candleRangeMode 미설정 + candleRange=4 → EXISTS_IN 호환");
|
"candleRangeMode 미설정 + candleRange=4 → CURRENT (윈도우 미적용)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -457,6 +457,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
<div className="seval-signal-body">
|
<div className="seval-signal-body">
|
||||||
<StrategyEvaluationSignalPanel
|
<StrategyEvaluationSignalPanel
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
|
strategy={selectedStrategy}
|
||||||
|
barTimeSec={selectedBarTimeSec}
|
||||||
barSignalHighlight={barSignalHighlight}
|
barSignalHighlight={barSignalHighlight}
|
||||||
hasStrategy={selectedStrategyId != null}
|
hasStrategy={selectedStrategyId != null}
|
||||||
className="seval-signal-panel"
|
className="seval-signal-panel"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom';
|
|||||||
import TradingChart from '../TradingChart';
|
import TradingChart from '../TradingChart';
|
||||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
|
||||||
import type { ChartManager } from '../../utils/ChartManager';
|
import type { ChartManager } from '../../utils/ChartManager';
|
||||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||||
@@ -16,9 +15,14 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
|||||||
import { getKoreanName } from '../../utils/marketNameCache';
|
import { getKoreanName } from '../../utils/marketNameCache';
|
||||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||||
import { loadBacktestSettings, runBacktest } 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 { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators';
|
||||||
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
|
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
|
||||||
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
|
|
||||||
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||||
import StrategyEvaluationBarSelector, {
|
import StrategyEvaluationBarSelector, {
|
||||||
resolveBarIndexAtChartClick,
|
resolveBarIndexAtChartClick,
|
||||||
@@ -136,6 +140,21 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
[strategy, getParams],
|
[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 applyMarkers = useCallback((attempt = 0) => {
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr?.hasMainSeries()) {
|
if (!mgr?.hasMainSeries()) {
|
||||||
@@ -167,6 +186,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
strategy.sellCondition,
|
strategy.sellCondition,
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
);
|
);
|
||||||
|
const evalCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, evaluationBarCount);
|
||||||
const res = await runBacktest({
|
const res = await runBacktest({
|
||||||
strategyId: strategy.id,
|
strategyId: strategy.id,
|
||||||
bars: barData.map(b => ({
|
bars: barData.map(b => ({
|
||||||
@@ -182,7 +202,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
strategyName: strategy.name,
|
strategyName: strategy.name,
|
||||||
settings: btSettings,
|
settings: btSettings,
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
evaluationBarCount,
|
evaluationBarCount: evalCount,
|
||||||
});
|
});
|
||||||
if (gen !== backtestGenRef.current) return;
|
if (gen !== backtestGenRef.current) return;
|
||||||
if (!res) {
|
if (!res) {
|
||||||
@@ -239,9 +259,15 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
managerRef.current?.clearBacktestMarkers();
|
managerRef.current?.clearBacktestMarkers();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const toTimeSec = Math.floor(Date.now() / 1000);
|
void fetchBacktestCandleBundle({
|
||||||
void loadAnalysisCandles(market, timeframe, toTimeSec, 300)
|
market,
|
||||||
.then(data => {
|
timeframe,
|
||||||
|
displayCount: BACKTEST_DISPLAY_BAR_COUNT,
|
||||||
|
buyDsl: strategy?.buyCondition,
|
||||||
|
sellDsl: strategy?.sellCondition,
|
||||||
|
indicatorParams,
|
||||||
|
})
|
||||||
|
.then(({ bars: data }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBars(data);
|
setBars(data);
|
||||||
onBarsLoaded?.(data);
|
onBarsLoaded?.(data);
|
||||||
@@ -258,7 +284,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
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 scrollToBar = useCallback((barIdx: number) => {
|
||||||
const mgr = chartMgr;
|
const mgr = chartMgr;
|
||||||
@@ -275,7 +301,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||||
}
|
}
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [paneAreaRatio, applyMarkers]);
|
fitChartViewport();
|
||||||
|
}, [paneAreaRatio, applyMarkers, fitChartViewport]);
|
||||||
|
|
||||||
const onCandlesReady = useCallback(() => {
|
const onCandlesReady = useCallback(() => {
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
@@ -284,7 +311,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||||
}
|
}
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [paneAreaRatio, applyMarkers]);
|
fitChartViewport();
|
||||||
|
}, [paneAreaRatio, applyMarkers, fitChartViewport]);
|
||||||
|
|
||||||
const stepBar = useCallback((delta: number) => {
|
const stepBar = useCallback((delta: number) => {
|
||||||
if (bars.length === 0) return;
|
if (bars.length === 0) return;
|
||||||
@@ -482,6 +510,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
showHoverToolbar={false}
|
showHoverToolbar={false}
|
||||||
showChartRightToolbar={false}
|
showChartRightToolbar={false}
|
||||||
showCandlePaneControls={false}
|
showCandlePaneControls={false}
|
||||||
|
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
||||||
/>
|
/>
|
||||||
{chartMgr && (
|
{chartMgr && (
|
||||||
<StrategyEvaluationBarSelector
|
<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 type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import {
|
import {
|
||||||
buildConditionMetrics,
|
buildConditionMetrics,
|
||||||
type ConditionMetric,
|
type ConditionMetric,
|
||||||
} from '../../utils/virtualSignalMetrics';
|
} from '../../utils/virtualSignalMetrics';
|
||||||
|
import {
|
||||||
|
buildConditionContextMap,
|
||||||
|
buildSideInterpretation,
|
||||||
|
} from '../../utils/strategyEvaluationInterpret';
|
||||||
import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual';
|
import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual';
|
||||||
|
import StrategyEvaluationInterpretModal from './StrategyEvaluationInterpretModal';
|
||||||
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
|
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||||
|
strategy?: StrategyDto | null;
|
||||||
|
barTimeSec?: number | null;
|
||||||
/** 선택 봉에 백테스트 매수/매도 시그널 존재 여부 */
|
/** 선택 봉에 백테스트 매수/매도 시그널 존재 여부 */
|
||||||
barSignalHighlight?: BarSignalHighlight;
|
barSignalHighlight?: BarSignalHighlight;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -17,49 +25,99 @@ interface Props {
|
|||||||
className?: string;
|
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 {
|
interface SideCardProps {
|
||||||
side: 'buy' | 'sell';
|
side: 'buy' | 'sell';
|
||||||
metrics: ConditionMetric[];
|
metrics: ConditionMetric[];
|
||||||
overallMet?: boolean | null;
|
overallMet?: boolean | null;
|
||||||
signalActive?: boolean;
|
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 title = side === 'buy' ? '매수 조건' : '매도 조건';
|
||||||
const overallLabel =
|
const overallLabel =
|
||||||
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
|
overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null;
|
||||||
|
|
||||||
|
const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]);
|
||||||
|
const interpretation = useMemo(
|
||||||
|
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
|
||||||
|
[side, metrics, overallMet, contextMap],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<>
|
||||||
className={[
|
<section
|
||||||
'seval-side-card',
|
className={[
|
||||||
`seval-side-card--${side}`,
|
'seval-side-card',
|
||||||
signalActive ? 'seval-side-card--signal-active' : '',
|
`seval-side-card--${side}`,
|
||||||
].filter(Boolean).join(' ')}
|
signalActive ? 'seval-side-card--signal-active' : '',
|
||||||
aria-label={`${title} 지표 일치율${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
aria-label={`${title} 지표 일치율${signalActive ? ' · 백테스트 시그널 발생 봉' : ''}`}
|
||||||
<header className="seval-side-card-head">
|
>
|
||||||
<div className="seval-side-card-head-main">
|
<header className="seval-side-card-head">
|
||||||
<span className={`seval-side-card-badge seval-side-card-badge--${side}`}>{title}</span>
|
<div className="seval-side-card-head-main">
|
||||||
{overallLabel && (
|
<span className={`seval-side-card-badge seval-side-card-badge--${side}`}>{title}</span>
|
||||||
<span
|
{overallLabel && (
|
||||||
className={`seval-side-overall seval-side-overall--${overallMet ? 'met' : 'unmet'}`}
|
<span
|
||||||
title="DSL 전체 Rule 충족 여부 (AND/OR 반영)"
|
className={`seval-side-overall seval-side-overall--${overallMet ? 'met' : 'unmet'}`}
|
||||||
>
|
title="DSL 전체 Rule 충족 여부 (AND/OR 반영)"
|
||||||
{overallLabel}
|
>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
</header>
|
</section>
|
||||||
<div className="seval-side-card-body">
|
|
||||||
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
|
{interpretOpen && (
|
||||||
</div>
|
<StrategyEvaluationInterpretModal
|
||||||
</section>
|
side={side}
|
||||||
|
interpretation={interpretation}
|
||||||
|
barTimeSec={barTimeSec}
|
||||||
|
strategyName={strategy?.name}
|
||||||
|
onClose={() => setInterpretOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||||
snapshot,
|
snapshot,
|
||||||
|
strategy,
|
||||||
|
barTimeSec,
|
||||||
barSignalHighlight,
|
barSignalHighlight,
|
||||||
loading = false,
|
loading = false,
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
@@ -92,12 +150,16 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
|||||||
metrics={sellMetrics}
|
metrics={sellMetrics}
|
||||||
overallMet={snapshot?.overallExitMet}
|
overallMet={snapshot?.overallExitMet}
|
||||||
signalActive={barSignalHighlight?.sell ?? false}
|
signalActive={barSignalHighlight?.sell ?? false}
|
||||||
|
strategy={strategy}
|
||||||
|
barTimeSec={barTimeSec}
|
||||||
/>
|
/>
|
||||||
<SideCard
|
<SideCard
|
||||||
side="buy"
|
side="buy"
|
||||||
metrics={buyMetrics}
|
metrics={buyMetrics}
|
||||||
overallMet={snapshot?.overallEntryMet}
|
overallMet={snapshot?.overallEntryMet}
|
||||||
signalActive={barSignalHighlight?.buy ?? false}
|
signalActive={barSignalHighlight?.buy ?? false}
|
||||||
|
strategy={strategy}
|
||||||
|
barTimeSec={barTimeSec}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1138,6 +1138,200 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 전략 판별 해석 버튼 · 팝업 ─────────────────────────────────────────── */
|
||||||
|
.seval-interpret-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-btn:hover:not(:disabled) {
|
||||||
|
color: var(--se-accent, #3f7ef5);
|
||||||
|
border-color: color-mix(in srgb, var(--se-accent, #3f7ef5) 45%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--se-accent, #3f7ef5) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overlay {
|
||||||
|
z-index: 10999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-modal {
|
||||||
|
max-height: min(88vh, 720px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-body {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: min(72vh, 600px);
|
||||||
|
padding: 12px 16px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-content {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-intro {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||||
|
background: color-mix(in srgb, var(--se-input-bg, #121826) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall--met {
|
||||||
|
border-color: color-mix(in srgb, #3f7ef5 35%, transparent);
|
||||||
|
background: color-mix(in srgb, #3f7ef5 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall--unmet {
|
||||||
|
border-color: color-mix(in srgb, var(--se-text-muted) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall--met .seval-interpret-overall-badge {
|
||||||
|
color: #7eb6ff;
|
||||||
|
background: color-mix(in srgb, #3f7ef5 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall--unmet .seval-interpret-overall-badge {
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
background: color-mix(in srgb, var(--se-text-muted) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall--unknown .seval-interpret-overall-badge {
|
||||||
|
color: #f0ad4e;
|
||||||
|
background: color-mix(in srgb, #f0ad4e 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-overall-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||||
|
background: color-mix(in srgb, var(--se-input-bg, #121826) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item--match {
|
||||||
|
border-left: 3px solid #3f7ef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item--nomatch {
|
||||||
|
border-left: 3px solid color-mix(in srgb, var(--se-text-muted) 55%, #ef5350);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item--unknown {
|
||||||
|
border-left: 3px solid #f0ad4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-status--match {
|
||||||
|
color: #7eb6ff;
|
||||||
|
background: color-mix(in srgb, #3f7ef5 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-status--nomatch {
|
||||||
|
color: #ff8a80;
|
||||||
|
background: color-mix(in srgb, #ef5350 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-status--unknown {
|
||||||
|
color: #f0ad4e;
|
||||||
|
background: color-mix(in srgb, #f0ad4e 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-item-title {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-reason {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-interpret-footnote {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.seval-chart-hint {
|
.seval-chart-hint {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
||||||
*/
|
*/
|
||||||
import type { LogicNode } from './strategyTypes';
|
import type { LogicNode } from './strategyTypes';
|
||||||
|
import { CONDITION_LABEL } from './strategyTypes';
|
||||||
import {
|
import {
|
||||||
resolveCandleRangeMode,
|
resolveCandleRangeMode,
|
||||||
candleRangeWindowBars,
|
candleRangeWindowBars,
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* 전략 평가 — 선택 봉 기준 조건별 충족/미충족 해석
|
||||||
|
*/
|
||||||
|
import type { StrategyDto } from './backendApi';
|
||||||
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||||
|
import {
|
||||||
|
formatCandleRangeClause,
|
||||||
|
resolveCandleRangeMode,
|
||||||
|
candleRangeWindowBars,
|
||||||
|
} from './strategyTypes';
|
||||||
|
import { asLogicNode } from './strategyHydrate';
|
||||||
|
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||||
|
import {
|
||||||
|
formatIndicatorValue,
|
||||||
|
formatVirtualConditionListLabel,
|
||||||
|
} from './virtualStrategyConditions';
|
||||||
|
import {
|
||||||
|
formatStrategyThreshold,
|
||||||
|
type ConditionMetric,
|
||||||
|
} from './virtualSignalMetrics';
|
||||||
|
import { nodeToText } from './strategyEditorShared';
|
||||||
|
|
||||||
|
export interface ConditionInterpretContext {
|
||||||
|
condition: ConditionDSL;
|
||||||
|
summary: string;
|
||||||
|
timeframe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionInterpretation {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
status: 'match' | 'nomatch' | 'unknown';
|
||||||
|
statusLabel: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideInterpretation {
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
sideTitle: string;
|
||||||
|
overallMet: boolean | null;
|
||||||
|
overallSummary: string;
|
||||||
|
conditions: ConditionInterpretation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkContext(
|
||||||
|
node: LogicNode | null | undefined,
|
||||||
|
timeframe: string,
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
out: Map<string, ConditionInterpretContext>,
|
||||||
|
seen: Set<string>,
|
||||||
|
): void {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (node.type === 'TIMEFRAME') {
|
||||||
|
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
||||||
|
node.children?.forEach(c => walkContext(c, tf, side, out, seen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'NOT') {
|
||||||
|
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
||||||
|
if (child) walkContext(child, timeframe, side, out, seen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'AND' || node.type === 'OR') {
|
||||||
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!node.type || node.type === 'CONDITION') && node.condition) {
|
||||||
|
const c = node.condition;
|
||||||
|
const rowTf = normalizeStartCandleType(c.leftCandleType ?? c.rightCandleType ?? timeframe);
|
||||||
|
const rowId = node.id ? `${node.id}-${side}` : `${side}:${rowTf}:${c.indicatorType}:${out.size}`;
|
||||||
|
if (seen.has(rowId)) return;
|
||||||
|
seen.add(rowId);
|
||||||
|
out.set(rowId, {
|
||||||
|
condition: c,
|
||||||
|
summary: nodeToText(node),
|
||||||
|
timeframe: rowTf,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConditionContextMap(
|
||||||
|
strategy: StrategyDto | null | undefined,
|
||||||
|
): Map<string, ConditionInterpretContext> {
|
||||||
|
const out = new Map<string, ConditionInterpretContext>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen);
|
||||||
|
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeNote(cond?: ConditionDSL): string {
|
||||||
|
if (!cond) return '';
|
||||||
|
const mode = resolveCandleRangeMode(cond);
|
||||||
|
if (mode === 'CURRENT') return '';
|
||||||
|
const n = candleRangeWindowBars(cond);
|
||||||
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `;
|
||||||
|
return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePhrase(
|
||||||
|
conditionType: string,
|
||||||
|
current: number | null,
|
||||||
|
target: number | null,
|
||||||
|
thresholdLabel: string | undefined,
|
||||||
|
satisfied: boolean,
|
||||||
|
): string {
|
||||||
|
const cur = formatIndicatorValue(current);
|
||||||
|
const ref = thresholdLabel
|
||||||
|
?? (target != null ? formatStrategyThreshold(conditionType, target, '') : '기준값');
|
||||||
|
|
||||||
|
if (current == null) {
|
||||||
|
return satisfied
|
||||||
|
? `조건(${ref})을(를) 충족합니다.`
|
||||||
|
: `조건(${ref})을(를) 충족하지 못했습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (conditionType) {
|
||||||
|
case 'GTE':
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur} ≥ ${ref.replace(/^≥\s*/, '')} 이므로 충족합니다.`
|
||||||
|
: `현재값 ${cur} < ${ref.replace(/^≥\s*/, '')} 이므로 미충족입니다.`;
|
||||||
|
case 'LTE':
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur} ≤ ${ref.replace(/^≤\s*/, '')} 이므로 충족합니다.`
|
||||||
|
: `현재값 ${cur} > ${ref.replace(/^≤\s*/, '')} 이므로 미충족입니다.`;
|
||||||
|
case 'GT':
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur} > ${ref.replace(/^>\s*/, '')} 이므로 충족합니다.`
|
||||||
|
: `현재값 ${cur} ≤ ${ref.replace(/^>\s*/, '')} 이므로 미충족입니다.`;
|
||||||
|
case 'LT':
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur} < ${ref.replace(/^<\s*/, '')} 이므로 충족합니다.`
|
||||||
|
: `현재값 ${cur} ≥ ${ref.replace(/^<\s*/, '')} 이므로 미충족입니다.`;
|
||||||
|
case 'EQ':
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur} = ${ref.replace(/^=\s*/, '')} 이므로 충족합니다.`
|
||||||
|
: `현재값 ${cur} ≠ ${ref.replace(/^=\s*/, '')} 이므로 미충족입니다.`;
|
||||||
|
default:
|
||||||
|
return satisfied
|
||||||
|
? `현재값 ${cur}, 기준 ${ref} — 조건 충족.`
|
||||||
|
: `현재값 ${cur}, 기준 ${ref} — 조건 미충족.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function crossPhrase(
|
||||||
|
cond: ConditionDSL | undefined,
|
||||||
|
conditionLabel: string,
|
||||||
|
thresholdLabel: string | undefined,
|
||||||
|
satisfied: boolean,
|
||||||
|
): string {
|
||||||
|
const range = cond ? formatCandleRangeClause(cond).trim() : '';
|
||||||
|
const mode = cond ? resolveCandleRangeMode(cond) : 'CURRENT';
|
||||||
|
const target = thresholdLabel ?? '비교 대상';
|
||||||
|
|
||||||
|
if (mode === 'EXISTS_IN') {
|
||||||
|
const n = cond ? candleRangeWindowBars(cond) : 0;
|
||||||
|
return satisfied
|
||||||
|
? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 확인되어 충족합니다.`
|
||||||
|
: `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 미충족입니다.`;
|
||||||
|
}
|
||||||
|
if (mode === 'NOT_EXISTS_IN') {
|
||||||
|
const n = cond ? candleRangeWindowBars(cond) : 0;
|
||||||
|
return satisfied
|
||||||
|
? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 충족합니다.`
|
||||||
|
: `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 있어 미충족입니다.`;
|
||||||
|
}
|
||||||
|
return satisfied
|
||||||
|
? `현재 봉에서 ${conditionLabel}(${target})이(가) 발생하여 충족합니다.`
|
||||||
|
: `현재 봉에서 ${conditionLabel}(${target})이(가) 없어 미충족입니다. (이미 돌파 후 유지 중인 경우에도 현재 봉 기준 미충족일 수 있습니다.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReason(
|
||||||
|
metric: ConditionMetric,
|
||||||
|
ctx?: ConditionInterpretContext,
|
||||||
|
): string {
|
||||||
|
const { row, status } = metric;
|
||||||
|
const cond = ctx?.condition;
|
||||||
|
const prefix = rangeNote(cond);
|
||||||
|
|
||||||
|
if (status === 'unknown') {
|
||||||
|
return `${prefix}봉 데이터 또는 지표 값이 부족하여 이 조건을 평가하지 못했습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = row.thresholdLabel
|
||||||
|
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel);
|
||||||
|
|
||||||
|
if (row.conditionType === 'CROSS_UP' || row.conditionType === 'CROSS_DOWN') {
|
||||||
|
return prefix + crossPhrase(cond, row.conditionLabel, row.thresholdLabel, status === 'match');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') {
|
||||||
|
const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락';
|
||||||
|
return prefix + (status === 'match'
|
||||||
|
? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.`
|
||||||
|
: `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.conditionType === 'HOLD_N_DAYS') {
|
||||||
|
const hd = cond?.holdDays ?? 3;
|
||||||
|
return prefix + (status === 'match'
|
||||||
|
? `조건이 ${hd}봉 연속 유지되어 충족합니다.`
|
||||||
|
: `조건이 ${hd}봉 연속 유지되지 않아 미충족입니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + comparePhrase(
|
||||||
|
row.conditionType,
|
||||||
|
row.currentValue,
|
||||||
|
row.targetValue,
|
||||||
|
threshold,
|
||||||
|
status === 'match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpretConditionMetric(
|
||||||
|
metric: ConditionMetric,
|
||||||
|
ctx?: ConditionInterpretContext,
|
||||||
|
): ConditionInterpretation {
|
||||||
|
const { row, status } = metric;
|
||||||
|
const label = formatVirtualConditionListLabel(row);
|
||||||
|
const summary = ctx?.summary ?? label;
|
||||||
|
|
||||||
|
const statusLabel = status === 'match'
|
||||||
|
? '충족'
|
||||||
|
: status === 'nomatch'
|
||||||
|
? '미충족'
|
||||||
|
: status === 'pending'
|
||||||
|
? '부분 충족'
|
||||||
|
: '평가 불가';
|
||||||
|
|
||||||
|
const normalizedStatus: ConditionInterpretation['status'] = status === 'match'
|
||||||
|
? 'match'
|
||||||
|
: status === 'nomatch'
|
||||||
|
? 'nomatch'
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
label,
|
||||||
|
summary,
|
||||||
|
status: normalizedStatus,
|
||||||
|
statusLabel,
|
||||||
|
reason: buildReason(metric, ctx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverallSummary(
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
metrics: ConditionMetric[],
|
||||||
|
overallMet: boolean | null | undefined,
|
||||||
|
): string {
|
||||||
|
const sideKo = side === 'buy' ? '매수' : '매도';
|
||||||
|
const evaluable = metrics.filter(m => m.status !== 'unknown');
|
||||||
|
const metCount = evaluable.filter(m => m.status === 'match').length;
|
||||||
|
|
||||||
|
if (evaluable.length === 0) {
|
||||||
|
return `${sideKo} 조건을 평가할 수 있는 데이터가 없습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overallMet === true) {
|
||||||
|
return `${sideKo} DSL 전체 Rule(AND/OR/NOT 포함)이 충족되었습니다. 아래 ${evaluable.length}개 조건이 동시에 true 입니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overallMet === false) {
|
||||||
|
if (metCount === 0) {
|
||||||
|
return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 조건 모두 false 입니다.`;
|
||||||
|
}
|
||||||
|
return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 중 ${metCount}개만 충족 — AND 결합 시 하나라도 false면 전체 false 입니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sideKo} 조건 ${evaluable.length}개 중 ${metCount}개 충족 (리프 조건 기준).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSideInterpretation(
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
metrics: ConditionMetric[],
|
||||||
|
overallMet: boolean | null | undefined,
|
||||||
|
contextMap: Map<string, ConditionInterpretContext>,
|
||||||
|
): SideInterpretation {
|
||||||
|
return {
|
||||||
|
side,
|
||||||
|
sideTitle: side === 'buy' ? '매수 조건' : '매도 조건',
|
||||||
|
overallMet: overallMet ?? null,
|
||||||
|
overallSummary: buildOverallSummary(side, metrics, overallMet),
|
||||||
|
conditions: metrics.map(m => interpretConditionMetric(m, contextMap.get(m.row.id))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBarTimeLabel(barTimeSec: number | null | undefined): string {
|
||||||
|
if (barTimeSec == null) return '선택 봉';
|
||||||
|
return new Date(barTimeSec * 1000).toLocaleString('ko-KR', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export interface ConditionDSL {
|
|||||||
holdDays?: number;
|
holdDays?: number;
|
||||||
/** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */
|
/** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */
|
||||||
lookbackPeriod?: number;
|
lookbackPeriod?: number;
|
||||||
/** 캔들 범위 모드 — 미설정 시 candleRange>1 이면 EXISTS_IN 으로 해석 */
|
/** CURRENT | EXISTS_IN | NOT_EXISTS_IN — 명시된 경우에만 윈도우 평가 */
|
||||||
candleRangeMode?: CandleRangeMode;
|
candleRangeMode?: CandleRangeMode;
|
||||||
/** EXISTS_IN/NOT_EXISTS_IN 일 때 윈도우 크기(N). CURRENT 일 때는 1 */
|
/** EXISTS_IN/NOT_EXISTS_IN 일 때 윈도우 크기(N). CURRENT 일 때는 1 */
|
||||||
candleRange?: number;
|
candleRange?: number;
|
||||||
@@ -372,13 +372,12 @@ export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */
|
/** candleRangeMode 가 명시된 경우에만 윈도우 모드 (미설정 시 CURRENT) */
|
||||||
export function resolveCandleRangeMode(
|
export function resolveCandleRangeMode(
|
||||||
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
cond: Pick<ConditionDSL, 'candleRangeMode' | 'candleRange'>,
|
||||||
): CandleRangeMode {
|
): CandleRangeMode {
|
||||||
if (cond.candleRangeMode) return cond.candleRangeMode;
|
if (cond.candleRangeMode) return cond.candleRangeMode;
|
||||||
const n = cond.candleRange ?? 1;
|
return 'CURRENT';
|
||||||
return n <= 1 ? 'CURRENT' : 'EXISTS_IN';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function candleRangeWindowBars(
|
export function candleRangeWindowBars(
|
||||||
|
|||||||
Reference in New Issue
Block a user