전략평가 화면 수익률 요약표시
This commit is contained in:
@@ -48,8 +48,14 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals
|
|||||||
import type { OHLCVBar } from '../types';
|
import type { OHLCVBar } from '../types';
|
||||||
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
|
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
|
||||||
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
|
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
|
||||||
import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport';
|
import {
|
||||||
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
buildStrategyEvaluationReportModel,
|
||||||
|
buildStrategyEvaluationToolbarSummary,
|
||||||
|
formatEvaluationReturnPct,
|
||||||
|
padEvaluationSignalCount,
|
||||||
|
} from '../utils/strategyEvaluationReport';
|
||||||
|
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
|
||||||
|
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
|
||||||
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
||||||
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
||||||
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
|
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
|
||||||
@@ -104,6 +110,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
||||||
|
|
||||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||||
|
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
|
||||||
|
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
|
||||||
|
warmupBarCount: 0,
|
||||||
|
isAtLatestWindow: true,
|
||||||
|
windowEndTimeSec: null,
|
||||||
|
});
|
||||||
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
|
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
|
||||||
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
||||||
const [evalLoading, setEvalLoading] = useState(false);
|
const [evalLoading, setEvalLoading] = useState(false);
|
||||||
@@ -112,6 +124,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const [paramsRevision, setParamsRevision] = useState(0);
|
const [paramsRevision, setParamsRevision] = useState(0);
|
||||||
const [chartLoading, setChartLoading] = useState(false);
|
const [chartLoading, setChartLoading] = useState(false);
|
||||||
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
||||||
|
const [signalScanRunning, setSignalScanRunning] = useState(false);
|
||||||
|
const [initialCapital, setInitialCapital] = useState(10_000_000);
|
||||||
const [editorModalOpen, setEditorModalOpen] = useState(false);
|
const [editorModalOpen, setEditorModalOpen] = useState(false);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
||||||
@@ -385,6 +399,20 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
setBacktestSignals(signals);
|
setBacktestSignals(signals);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBacktestSettings().then(s => {
|
||||||
|
if (s?.initialCapital) setInitialCapital(s.initialCapital);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const analysisSummary = useMemo(() => {
|
||||||
|
if (!selectedStrategy) return null;
|
||||||
|
return buildStrategyEvaluationToolbarSummary(backtestSignals, {
|
||||||
|
initialCapital,
|
||||||
|
symbol: market.replace(/^KRW-/, ''),
|
||||||
|
});
|
||||||
|
}, [selectedStrategy, backtestSignals, initialCapital, market]);
|
||||||
|
|
||||||
const refreshEvaluation = useCallback(async () => {
|
const refreshEvaluation = useCallback(async () => {
|
||||||
if (!selectedStrategyId || !selectedStrategy) {
|
if (!selectedStrategyId || !selectedStrategy) {
|
||||||
setSnapshot(undefined);
|
setSnapshot(undefined);
|
||||||
@@ -488,8 +516,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const reportCacheKey = useMemo(() => {
|
const reportCacheKey = useMemo(() => {
|
||||||
if (!selectedStrategyId || bars.length < 10) return null;
|
if (!selectedStrategyId || bars.length < 10) return null;
|
||||||
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
|
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
|
||||||
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}`;
|
const windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest';
|
||||||
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars]);
|
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}`;
|
||||||
|
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec]);
|
||||||
|
|
||||||
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
|
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
|
||||||
|
|
||||||
@@ -517,6 +546,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
||||||
|
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
|
||||||
const context = buildStrategyEvaluationAiVerifyContext({
|
const context = buildStrategyEvaluationAiVerifyContext({
|
||||||
strategy: selectedStrategy,
|
strategy: selectedStrategy,
|
||||||
strategyId: selectedStrategyId,
|
strategyId: selectedStrategyId,
|
||||||
@@ -530,7 +560,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
chartSignals: backtestSignals,
|
chartSignals: backtestSignals,
|
||||||
barSignalHighlight,
|
barSignalHighlight,
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
evaluationBarCount: Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length),
|
evaluationBarCount,
|
||||||
});
|
});
|
||||||
setAiVerifyContext(context);
|
setAiVerifyContext(context);
|
||||||
|
|
||||||
@@ -568,6 +598,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
backtestSignals,
|
backtestSignals,
|
||||||
barSignalHighlight,
|
barSignalHighlight,
|
||||||
getEvalParams,
|
getEvalParams,
|
||||||
|
evalWindowMeta.evaluationBarCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenAiVerify = useCallback(() => {
|
const handleOpenAiVerify = useCallback(() => {
|
||||||
@@ -590,7 +621,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
try {
|
try {
|
||||||
const [btSettings] = await Promise.all([loadBacktestSettings()]);
|
const [btSettings] = await Promise.all([loadBacktestSettings()]);
|
||||||
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
||||||
const evaluationBarCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length);
|
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
|
||||||
|
const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount);
|
||||||
const res = await runBacktest({
|
const res = await runBacktest({
|
||||||
strategyId: selectedStrategyId,
|
strategyId: selectedStrategyId,
|
||||||
bars: bars.map(b => ({
|
bars: bars.map(b => ({
|
||||||
@@ -621,9 +653,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
strategyName: selectedStrategy.name ?? strategyLabel,
|
strategyName: selectedStrategy.name ?? strategyLabel,
|
||||||
symbol: market.replace(/^KRW-/, ''),
|
symbol: market.replace(/^KRW-/, ''),
|
||||||
timeframe: chartTimeframe,
|
timeframe: chartTimeframe,
|
||||||
barCount: bars.length,
|
barCount: evaluationBarCount,
|
||||||
firstBarClose: bars[0]?.close,
|
firstBarClose: evalBars[0]?.close,
|
||||||
lastBarClose: bars[bars.length - 1]?.close,
|
lastBarClose: evalBars[evalBars.length - 1]?.close,
|
||||||
});
|
});
|
||||||
if (!model) {
|
if (!model) {
|
||||||
window.alert('분석 데이터를 생성할 수 없습니다.');
|
window.alert('분석 데이터를 생성할 수 없습니다.');
|
||||||
@@ -650,6 +682,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
chartTimeframe,
|
chartTimeframe,
|
||||||
market,
|
market,
|
||||||
strategyLabel,
|
strategyLabel,
|
||||||
|
evalWindowMeta.evaluationBarCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -659,6 +692,45 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
<h1 className="btd-header-title">전략 평가</h1>
|
<h1 className="btd-header-title">전략 평가</h1>
|
||||||
<span className="btd-header-sub">Strategy Match Analysis</span>
|
<span className="btd-header-sub">Strategy Match Analysis</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="btd-header-actions seval-header-analysis">
|
||||||
|
{selectedStrategy && signalScanRunning && (
|
||||||
|
<span className="seval-header-analysis-busy">시그널 계산 중…</span>
|
||||||
|
)}
|
||||||
|
{selectedStrategy && analysisSummary && !signalScanRunning && (
|
||||||
|
<span
|
||||||
|
className="seval-analysis-summary"
|
||||||
|
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 레포트와 동일 필터 기준"
|
||||||
|
>
|
||||||
|
<span className="seval-analysis-item">
|
||||||
|
매수:<strong>{padEvaluationSignalCount(analysisSummary.buyCount)}</strong>건
|
||||||
|
</span>
|
||||||
|
<span className="seval-analysis-sep" aria-hidden>·</span>
|
||||||
|
<span className="seval-analysis-item">
|
||||||
|
매도:<strong>{padEvaluationSignalCount(analysisSummary.sellCount)}</strong>건
|
||||||
|
</span>
|
||||||
|
<span className="seval-analysis-sep" aria-hidden>·</span>
|
||||||
|
<span className={`seval-analysis-item seval-analysis-return${
|
||||||
|
analysisSummary.fullBuyReturnPct > 0
|
||||||
|
? ' seval-analysis-return--pos'
|
||||||
|
: analysisSummary.fullBuyReturnPct < 0
|
||||||
|
? ' seval-analysis-return--neg'
|
||||||
|
: ''
|
||||||
|
}`}>
|
||||||
|
일괄:<strong>{formatEvaluationReturnPct(analysisSummary.fullBuyReturnPct)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="seval-analysis-sep" aria-hidden>·</span>
|
||||||
|
<span className={`seval-analysis-item seval-analysis-return${
|
||||||
|
analysisSummary.splitBuyReturnPct > 0
|
||||||
|
? ' seval-analysis-return--pos'
|
||||||
|
: analysisSummary.splitBuyReturnPct < 0
|
||||||
|
? ' seval-analysis-return--neg'
|
||||||
|
: ''
|
||||||
|
}`}>
|
||||||
|
분할:<strong>{formatEvaluationReturnPct(analysisSummary.splitBuyReturnPct)}</strong>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="btd-body">
|
<div className="btd-body">
|
||||||
@@ -819,12 +891,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
selectedBarIndex={selectedBarIndex}
|
selectedBarIndex={selectedBarIndex}
|
||||||
onSelectedBarIndexChange={setSelectedBarIndex}
|
onSelectedBarIndexChange={setSelectedBarIndex}
|
||||||
onBarsLoaded={setBars}
|
onBarsLoaded={setBars}
|
||||||
|
onEvalWindowMetaChange={setEvalWindowMeta}
|
||||||
paramsRevision={paramsRevision}
|
paramsRevision={paramsRevision}
|
||||||
appliedIndicatorParams={appliedIndicatorParams}
|
appliedIndicatorParams={appliedIndicatorParams}
|
||||||
getParamsOverride={getEvalParams}
|
getParamsOverride={getEvalParams}
|
||||||
getVisualConfigOverride={getVisualConfig}
|
getVisualConfigOverride={getVisualConfig}
|
||||||
evalLoading={evalLoading}
|
evalLoading={evalLoading}
|
||||||
onSignalsChange={handleSignalsChange}
|
onSignalsChange={handleSignalsChange}
|
||||||
|
onBacktestRunningChange={setSignalScanRunning}
|
||||||
onOpenReport={() => { void handleOpenReport(); }}
|
onOpenReport={() => { void handleOpenReport(); }}
|
||||||
reportDisabled={reportDisabled}
|
reportDisabled={reportDisabled}
|
||||||
reportLoading={reportLoading}
|
reportLoading={reportLoading}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
|
|||||||
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
||||||
import {
|
import {
|
||||||
BACKTEST_DISPLAY_BAR_COUNT,
|
BACKTEST_DISPLAY_BAR_COUNT,
|
||||||
|
computeNextWindowEndTime,
|
||||||
|
computePreviousWindowEndTime,
|
||||||
fetchBacktestCandleBundle,
|
fetchBacktestCandleBundle,
|
||||||
} from '../../utils/backtestWarmup';
|
} from '../../utils/backtestWarmup';
|
||||||
import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams';
|
import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams';
|
||||||
@@ -52,6 +54,13 @@ import {
|
|||||||
} from '../../utils/backtestRunTimeframe';
|
} from '../../utils/backtestRunTimeframe';
|
||||||
import { timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
import { timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||||
|
|
||||||
|
export interface StrategyEvaluationWindowMeta {
|
||||||
|
evaluationBarCount: number;
|
||||||
|
warmupBarCount: number;
|
||||||
|
isAtLatestWindow: boolean;
|
||||||
|
windowEndTimeSec: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
timeframe: Timeframe;
|
timeframe: Timeframe;
|
||||||
@@ -68,6 +77,7 @@ interface Props {
|
|||||||
selectedBarIndex: number;
|
selectedBarIndex: number;
|
||||||
onSelectedBarIndexChange: (index: number) => void;
|
onSelectedBarIndexChange: (index: number) => void;
|
||||||
onBarsLoaded?: (bars: OHLCVBar[]) => void;
|
onBarsLoaded?: (bars: OHLCVBar[]) => void;
|
||||||
|
onEvalWindowMetaChange?: (meta: StrategyEvaluationWindowMeta) => void;
|
||||||
paramsRevision?: number;
|
paramsRevision?: number;
|
||||||
/** 사용자가 저장한 지표 파라미터 — null 이면 백테스트도 서버 DB 설정 사용 (live-conditions 와 동일) */
|
/** 사용자가 저장한 지표 파라미터 — null 이면 백테스트도 서버 DB 설정 사용 (live-conditions 와 동일) */
|
||||||
appliedIndicatorParams?: EvalIndicatorParams | null;
|
appliedIndicatorParams?: EvalIndicatorParams | null;
|
||||||
@@ -88,6 +98,7 @@ interface Props {
|
|||||||
evalLoading?: boolean;
|
evalLoading?: boolean;
|
||||||
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
|
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
|
||||||
onSignalsChange?: (signals: BacktestSignal[]) => void;
|
onSignalsChange?: (signals: BacktestSignal[]) => void;
|
||||||
|
onBacktestRunningChange?: (running: boolean) => void;
|
||||||
/** 투자분석 상세 레포트 (인쇄·PDF) */
|
/** 투자분석 상세 레포트 (인쇄·PDF) */
|
||||||
onOpenReport?: () => void;
|
onOpenReport?: () => void;
|
||||||
reportDisabled?: boolean;
|
reportDisabled?: boolean;
|
||||||
@@ -132,12 +143,14 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
selectedBarIndex,
|
selectedBarIndex,
|
||||||
onSelectedBarIndexChange,
|
onSelectedBarIndexChange,
|
||||||
onBarsLoaded,
|
onBarsLoaded,
|
||||||
|
onEvalWindowMetaChange,
|
||||||
paramsRevision = 0,
|
paramsRevision = 0,
|
||||||
appliedIndicatorParams = null,
|
appliedIndicatorParams = null,
|
||||||
getParamsOverride,
|
getParamsOverride,
|
||||||
getVisualConfigOverride,
|
getVisualConfigOverride,
|
||||||
evalLoading = false,
|
evalLoading = false,
|
||||||
onSignalsChange,
|
onSignalsChange,
|
||||||
|
onBacktestRunningChange,
|
||||||
onOpenReport,
|
onOpenReport,
|
||||||
reportDisabled = false,
|
reportDisabled = false,
|
||||||
reportLoading = false,
|
reportLoading = false,
|
||||||
@@ -181,6 +194,11 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
|
|
||||||
const backtestGenRef = useRef(0);
|
const backtestGenRef = useRef(0);
|
||||||
const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT);
|
const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT);
|
||||||
|
const warmupBarCountRef = useRef(0);
|
||||||
|
const latestBarTimeSecRef = useRef<number | null>(null);
|
||||||
|
type EvaluationWindowAnchor = 'latest' | number;
|
||||||
|
const [windowAnchor, setWindowAnchor] = useState<EvaluationWindowAnchor>('latest');
|
||||||
|
const [displayEvalBarCount, setDisplayEvalBarCount] = useState(BACKTEST_DISPLAY_BAR_COUNT);
|
||||||
const signalsRef = useRef(signals);
|
const signalsRef = useRef(signals);
|
||||||
signalsRef.current = signals;
|
signalsRef.current = signals;
|
||||||
const chartType: ChartType = 'candlestick';
|
const chartType: ChartType = 'candlestick';
|
||||||
@@ -428,8 +446,25 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
}, [signals, onSignalsChange]);
|
}, [signals, onSignalsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChartLoadingChange?.(loading);
|
onBacktestRunningChange?.(backtestRunning);
|
||||||
}, [loading, onChartLoadingChange]);
|
}, [backtestRunning, onBacktestRunningChange]);
|
||||||
|
|
||||||
|
const emitEvalWindowMeta = useCallback((
|
||||||
|
evalCount: number,
|
||||||
|
warmupCount: number,
|
||||||
|
anchor: EvaluationWindowAnchor,
|
||||||
|
) => {
|
||||||
|
onEvalWindowMetaChange?.({
|
||||||
|
evaluationBarCount: evalCount,
|
||||||
|
warmupBarCount: warmupCount,
|
||||||
|
isAtLatestWindow: anchor === 'latest',
|
||||||
|
windowEndTimeSec: anchor === 'latest' ? null : anchor,
|
||||||
|
});
|
||||||
|
}, [onEvalWindowMetaChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWindowAnchor('latest');
|
||||||
|
}, [market, timeframe, strategy?.id, paramsRevision]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -440,6 +475,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
managerRef.current?.clearBacktestMarkers();
|
managerRef.current?.clearBacktestMarkers();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const toTimeSec = windowAnchor === 'latest' ? undefined : windowAnchor;
|
||||||
void fetchBacktestCandleBundle({
|
void fetchBacktestCandleBundle({
|
||||||
market,
|
market,
|
||||||
timeframe,
|
timeframe,
|
||||||
@@ -447,12 +484,19 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
buyDsl: strategy?.buyCondition,
|
buyDsl: strategy?.buyCondition,
|
||||||
sellDsl: strategy?.sellCondition,
|
sellDsl: strategy?.sellCondition,
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
|
toTimeSec,
|
||||||
})
|
})
|
||||||
.then(({ bars: data, evaluationBarCount: evalCount }) => {
|
.then(({ bars: data, evaluationBarCount: evalCount, warmupBarCount }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
evaluationBarCountRef.current = evalCount;
|
evaluationBarCountRef.current = evalCount;
|
||||||
|
warmupBarCountRef.current = warmupBarCount;
|
||||||
|
setDisplayEvalBarCount(evalCount);
|
||||||
|
if (windowAnchor === 'latest') {
|
||||||
|
latestBarTimeSecRef.current = data[data.length - 1]?.time ?? null;
|
||||||
|
}
|
||||||
setBars(data);
|
setBars(data);
|
||||||
onBarsLoaded?.(data);
|
onBarsLoaded?.(data);
|
||||||
|
emitEvalWindowMeta(evalCount, warmupBarCount, windowAnchor);
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
onSelectedBarIndexChange(data.length - 1);
|
onSelectedBarIndexChange(data.length - 1);
|
||||||
}
|
}
|
||||||
@@ -466,7 +510,17 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [market, timeframe, strategy?.id, paramsRevision, onBarsLoaded, onSelectedBarIndexChange]);
|
}, [
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
strategy?.id,
|
||||||
|
paramsRevision,
|
||||||
|
indicatorParamsKey,
|
||||||
|
windowAnchor,
|
||||||
|
onBarsLoaded,
|
||||||
|
onSelectedBarIndexChange,
|
||||||
|
emitEvalWindowMeta,
|
||||||
|
]);
|
||||||
|
|
||||||
const scrollToBar = useCallback((barIdx: number) => {
|
const scrollToBar = useCallback((barIdx: number) => {
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
@@ -523,6 +577,31 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onSelectedBarIndexChange(bars.length - 1);
|
onSelectedBarIndexChange(bars.length - 1);
|
||||||
}, [bars.length, onSelectedBarIndexChange]);
|
}, [bars.length, onSelectedBarIndexChange]);
|
||||||
|
|
||||||
|
const isAtLatestWindow = windowAnchor === 'latest';
|
||||||
|
|
||||||
|
const shiftEvaluationWindow = useCallback((direction: 'prev' | 'next') => {
|
||||||
|
if (loading || bars.length === 0) return;
|
||||||
|
const evalCount = evaluationBarCountRef.current;
|
||||||
|
const barSec = timeframeBarSeconds(timeframe);
|
||||||
|
|
||||||
|
if (direction === 'next') {
|
||||||
|
if (isAtLatestWindow) return;
|
||||||
|
const next = computeNextWindowEndTime(
|
||||||
|
bars,
|
||||||
|
evalCount,
|
||||||
|
latestBarTimeSecRef.current,
|
||||||
|
barSec,
|
||||||
|
);
|
||||||
|
if (next == null) return;
|
||||||
|
setWindowAnchor(next === 'latest' ? 'latest' : next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevEnd = computePreviousWindowEndTime(bars, evalCount, barSec);
|
||||||
|
if (prevEnd == null) return;
|
||||||
|
setWindowAnchor(prevEnd);
|
||||||
|
}, [loading, bars, timeframe, isAtLatestWindow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mgr = chartMgr;
|
const mgr = chartMgr;
|
||||||
if (!mgr || bars.length === 0) return;
|
if (!mgr || bars.length === 0) return;
|
||||||
@@ -601,6 +680,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onMarketSearchOpenChange?.(showMarketSearch);
|
onMarketSearchOpenChange?.(showMarketSearch);
|
||||||
}, [showMarketSearch, onMarketSearchOpenChange]);
|
}, [showMarketSearch, onMarketSearchOpenChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChartLoadingChange?.(loading);
|
||||||
|
}, [loading, onChartLoadingChange]);
|
||||||
|
|
||||||
const selectedBar = bars[selectedBarIndex] ?? null;
|
const selectedBar = bars[selectedBarIndex] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -655,11 +738,6 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
|
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{strategy && signals.length > 0 && !backtestRunning && (
|
|
||||||
<span className="seval-chart-signal-count" title="조건 충족 매매 시그널">
|
|
||||||
시그널 {signals.length}건
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{strategy && backtestRunning && (
|
{strategy && backtestRunning && (
|
||||||
<span className="seval-chart-backtest-busy">시그널 계산 중…</span>
|
<span className="seval-chart-backtest-busy">시그널 계산 중…</span>
|
||||||
)}
|
)}
|
||||||
@@ -818,37 +896,67 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="seval-chart-bottom-toolbar">
|
<div className="seval-chart-bottom-toolbar">
|
||||||
<button
|
<div className="seval-chart-bottom-side seval-chart-bottom-side--left">
|
||||||
type="button"
|
<button
|
||||||
className="btd-btn btd-btn--ghost seval-chart-nav--step"
|
type="button"
|
||||||
title="이전 봉 (←)"
|
className="btd-btn btd-btn--ghost seval-chart-nav--window"
|
||||||
onClick={() => stepBar(-1)}
|
title={`이전 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가 (워밍업 포함 재조회)`}
|
||||||
disabled={selectedBarIndex <= 0 || bars.length === 0}
|
onClick={() => shiftEvaluationWindow('prev')}
|
||||||
>
|
disabled={loading || bars.length === 0}
|
||||||
◀ 이전 봉
|
>
|
||||||
</button>
|
◀◀ 이전봉 평가
|
||||||
<span className="seval-chart-count">
|
</button>
|
||||||
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
|
</div>
|
||||||
</span>
|
<div className="seval-chart-bottom-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btd-btn btd-btn--ghost seval-chart-nav--step"
|
className="btd-btn btd-btn--ghost seval-chart-nav--step"
|
||||||
title="다음 봉 (→)"
|
title="이전 봉 (←)"
|
||||||
onClick={() => stepBar(1)}
|
onClick={() => stepBar(-1)}
|
||||||
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
|
disabled={selectedBarIndex <= 0 || bars.length === 0}
|
||||||
>
|
>
|
||||||
다음 봉 ▶
|
◀ 이전 봉
|
||||||
</button>
|
</button>
|
||||||
<span className="seval-chart-bottom-divider" aria-hidden />
|
<span className="seval-chart-count">
|
||||||
<button
|
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
|
||||||
type="button"
|
{bars.length > 0 && (
|
||||||
className="btd-btn btd-btn--ghost"
|
<span className="seval-chart-eval-range">
|
||||||
title="최신 봉"
|
{isAtLatestWindow ? ' · 최신 평가' : ' · 과거 평가'}
|
||||||
onClick={goLatest}
|
{` · ${Math.min(displayEvalBarCount, bars.length)}봉`}
|
||||||
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
|
</span>
|
||||||
>
|
)}
|
||||||
최신
|
</span>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btd-btn btd-btn--ghost seval-chart-nav--step"
|
||||||
|
title="다음 봉 (→)"
|
||||||
|
onClick={() => stepBar(1)}
|
||||||
|
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
|
||||||
|
>
|
||||||
|
다음 봉 ▶
|
||||||
|
</button>
|
||||||
|
<span className="seval-chart-bottom-divider" aria-hidden />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btd-btn btd-btn--ghost"
|
||||||
|
title="최신 봉"
|
||||||
|
onClick={goLatest}
|
||||||
|
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
|
||||||
|
>
|
||||||
|
최신
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="seval-chart-bottom-side seval-chart-bottom-side--right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btd-btn btd-btn--ghost seval-chart-nav--window"
|
||||||
|
title={`다음 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가`}
|
||||||
|
onClick={() => shiftEvaluationWindow('next')}
|
||||||
|
disabled={loading || bars.length === 0 || isAtLatestWindow}
|
||||||
|
>
|
||||||
|
이후봉 평가 ▶▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
|
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
|
||||||
|
|||||||
@@ -945,6 +945,53 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-header-analysis {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-header-analysis-busy {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-analysis-summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--se-text-muted) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-border, #30363d) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-analysis-item strong {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--se-text, var(--text));
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-analysis-sep {
|
||||||
|
opacity: 0.45;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-analysis-return--pos strong {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-analysis-return--neg strong {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-chart-backtest-busy {
|
.seval-chart-backtest-busy {
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
@@ -967,15 +1014,46 @@
|
|||||||
|
|
||||||
.seval-chart-bottom-toolbar {
|
.seval-chart-bottom-toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-top: 1px solid var(--btd-divider, var(--se-border));
|
border-top: 1px solid var(--btd-divider, var(--se-border));
|
||||||
background: var(--se-header-bg, var(--se-bg-elevated));
|
background: var(--se-header-bg, var(--se-bg-elevated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-chart-bottom-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-bottom-side--left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-bottom-side--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-bottom-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-nav--window {
|
||||||
|
min-width: 108px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-eval-range {
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-chart-nav--step {
|
.seval-chart-nav--step {
|
||||||
min-width: 88px;
|
min-width: 88px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,56 @@ export async function fetchBacktestCandleBundle(opts: {
|
|||||||
return { bars, evaluationBarCount, warmupBarCount };
|
return { bars, evaluationBarCount, warmupBarCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 로드된 캔들에서 실제 Rule 평가 구간(마지막 evaluationBarCount 봉) */
|
||||||
|
export function resolveEvaluationBarSlice(
|
||||||
|
bars: OHLCVBar[],
|
||||||
|
evaluationBarCount: number,
|
||||||
|
): { evalBars: OHLCVBar[]; firstEvalIndex: number } {
|
||||||
|
const count = Math.min(Math.max(evaluationBarCount, 1), bars.length);
|
||||||
|
const firstEvalIndex = Math.max(0, bars.length - count);
|
||||||
|
return { evalBars: bars.slice(firstEvalIndex), firstEvalIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 200봉 평가 구간 — 현재 평가 창 직전 봉을 새 창의 종료 시점(to)으로 사용.
|
||||||
|
* 워밍업은 fetchBacktestCandleBundle 이 별도로 앞쪽에 추가 요청한다.
|
||||||
|
*/
|
||||||
|
export function computePreviousWindowEndTime(
|
||||||
|
bars: OHLCVBar[],
|
||||||
|
evaluationBarCount: number,
|
||||||
|
barSec: number,
|
||||||
|
): number | null {
|
||||||
|
if (bars.length === 0) return null;
|
||||||
|
const { firstEvalIndex } = resolveEvaluationBarSlice(bars, evaluationBarCount);
|
||||||
|
if (firstEvalIndex > 0) {
|
||||||
|
return bars[firstEvalIndex - 1].time;
|
||||||
|
}
|
||||||
|
return bars[0].time - Math.max(barSec, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이후 200봉 평가 구간 — 현재 창에서 evaluationBarCount 만큼 앞으로 이동.
|
||||||
|
* 'latest' = 최신 데이터 창으로 복귀, null = 이미 최신(이동 불가).
|
||||||
|
*/
|
||||||
|
export function computeNextWindowEndTime(
|
||||||
|
bars: OHLCVBar[],
|
||||||
|
evaluationBarCount: number,
|
||||||
|
latestBarTimeSec: number | null,
|
||||||
|
barSec: number,
|
||||||
|
): 'latest' | number | null {
|
||||||
|
if (bars.length === 0) return null;
|
||||||
|
const lastBarTime = bars[bars.length - 1].time;
|
||||||
|
const tolerance = Math.max(barSec / 2, 1);
|
||||||
|
if (latestBarTimeSec != null && lastBarTime >= latestBarTimeSec - tolerance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nextEndTime = lastBarTime + evaluationBarCount * Math.max(barSec, 1);
|
||||||
|
if (latestBarTimeSec != null && nextEndTime >= latestBarTimeSec - tolerance) {
|
||||||
|
return 'latest';
|
||||||
|
}
|
||||||
|
return nextEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
/** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */
|
/** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */
|
||||||
export function resolveEvaluationFromLoadedBars(
|
export function resolveEvaluationFromLoadedBars(
|
||||||
barCount: number,
|
barCount: number,
|
||||||
|
|||||||
@@ -285,6 +285,60 @@ const ALLOCATION_LABELS: Record<BuyAllocationMode, string> = {
|
|||||||
split: '분할매수 (1차 40% · 2차 30% · 3차 잔여 전액)',
|
split: '분할매수 (1차 40% · 2차 30% · 3차 잔여 전액)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StrategyEvaluationToolbarSummary {
|
||||||
|
buyCount: number;
|
||||||
|
sellCount: number;
|
||||||
|
/** 일괄매수 수익률 (소수) */
|
||||||
|
fullBuyReturnPct: number;
|
||||||
|
/** 분할매수 수익률 (소수) */
|
||||||
|
splitBuyReturnPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function padEvaluationSignalCount(n: number): string {
|
||||||
|
return String(Math.max(0, Math.floor(n))).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEvaluationReturnPct(v: number): string {
|
||||||
|
if (!Number.isFinite(v) || v === 0) return '0.00%';
|
||||||
|
return `${v >= 0 ? '+' : ''}${(v * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 차트 상단 툴바 — 필터·일괄/분할매수 시뮬레이션 기준 요약 */
|
||||||
|
export function buildStrategyEvaluationToolbarSummary(
|
||||||
|
signals: BacktestSignal[],
|
||||||
|
opts?: {
|
||||||
|
initialCapital?: number;
|
||||||
|
symbol?: string;
|
||||||
|
},
|
||||||
|
): StrategyEvaluationToolbarSummary {
|
||||||
|
const filtered = filterSignalsForEvaluationReport(signals);
|
||||||
|
let buyCount = 0;
|
||||||
|
let sellCount = 0;
|
||||||
|
for (const s of filtered) {
|
||||||
|
if (BUY_TYPES.has(s.type)) buyCount += 1;
|
||||||
|
else if (SELL_TYPES.has(s.type)) sellCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = { buyCount: 0, sellCount: 0, fullBuyReturnPct: 0, splitBuyReturnPct: 0 };
|
||||||
|
if (filtered.length === 0) return empty;
|
||||||
|
|
||||||
|
const capital = opts?.initialCapital ?? 10_000_000;
|
||||||
|
const symbol = opts?.symbol ?? '';
|
||||||
|
const stillHolding = isStillHolding(filtered);
|
||||||
|
|
||||||
|
const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full');
|
||||||
|
const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split');
|
||||||
|
const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding);
|
||||||
|
const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buyCount,
|
||||||
|
sellCount,
|
||||||
|
fullBuyReturnPct: fullAnalysis.totalReturnPct,
|
||||||
|
splitBuyReturnPct: splitAnalysis.totalReturnPct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 전략평가 화면 — 필터·시뮬레이션 기반 분석 레포트 모델 */
|
/** 전략평가 화면 — 필터·시뮬레이션 기반 분석 레포트 모델 */
|
||||||
export function buildStrategyEvaluationReportModel(opts: {
|
export function buildStrategyEvaluationReportModel(opts: {
|
||||||
signals: BacktestSignal[];
|
signals: BacktestSignal[];
|
||||||
|
|||||||
Reference in New Issue
Block a user