분석결과 레포트 수정
This commit is contained in:
@@ -44,6 +44,91 @@ class ComplexStrategyDslAdapterTest {
|
|||||||
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OR(AND, AND) — 4개 조건을 평탄 OR로 처리하면 true가 되는 케이스에서 false여야 함 */
|
||||||
|
@Test
|
||||||
|
void orAndNested_doesNotFlattenChildrenToOr() throws Exception {
|
||||||
|
JsonNode buy = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"type": "OR",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
||||||
|
}},
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
||||||
|
}},
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Rule rule = adapter.toRule(buy, primary1m, Map.of());
|
||||||
|
int idx = primary1m.getEndIndex();
|
||||||
|
// 평탄 OR(4조건)이면 close>K_1 하나만으로 true — AND 중첩이면 각 분기 false → 전체 false
|
||||||
|
assertFalse(rule.isSatisfied(idx, null),
|
||||||
|
"OR(AND)는 분기 내 모든 조건이 충족될 때만 true — 단일 true 자식으로는 true가 되면 안 됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orAndNested_oneFullAndBranchSatisfies() throws Exception {
|
||||||
|
JsonNode buy = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"type": "OR",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
||||||
|
}},
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_0", "candleRange": 1
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
||||||
|
}},
|
||||||
|
{ "type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "MA", "conditionType": "GT",
|
||||||
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Rule rule = adapter.toRule(buy, primary1m, Map.of());
|
||||||
|
assertTrue(rule.isSatisfied(primary1m.getEndIndex(), null),
|
||||||
|
"OR(AND) — 한 분기의 AND가 모두 true이면 전체 true");
|
||||||
|
}
|
||||||
|
|
||||||
/** Logic Expression 예시와 동일 구조의 매수 조건 */
|
/** Logic Expression 예시와 동일 구조의 매수 조건 */
|
||||||
@Test
|
@Test
|
||||||
void buyCondition_compilesAndEvaluates() throws Exception {
|
void buyCondition_compilesAndEvaluates() throws Exception {
|
||||||
|
|||||||
@@ -9287,6 +9287,70 @@ html.desktop-client .tmb-logo-version {
|
|||||||
.brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); }
|
.brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); }
|
||||||
.brd-pnl-net-val { font-size: 15px; font-weight: 800; }
|
.brd-pnl-net-val { font-size: 15px; font-weight: 800; }
|
||||||
|
|
||||||
|
/* ── 전략평가 — 매수 방식별 비교 ── */
|
||||||
|
.brd-eval-filter-note {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text3);
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.brd-alloc-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.brd-alloc-tab {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.brd-alloc-tab--active {
|
||||||
|
border-color: var(--brd-blue);
|
||||||
|
background: color-mix(in srgb, var(--brd-blue) 12%, var(--bg2));
|
||||||
|
color: var(--brd-blue);
|
||||||
|
}
|
||||||
|
.brd-alloc-compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.brd-alloc-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.brd-alloc-card--active {
|
||||||
|
border-color: var(--brd-blue);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brd-blue) 35%, transparent);
|
||||||
|
}
|
||||||
|
.brd-alloc-card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.brd-alloc-card-return {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.brd-alloc-card-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.brd-card--full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
/* ── 시그널 테이블 ── */
|
/* ── 시그널 테이블 ── */
|
||||||
.brd-sig-scroll { max-height: 200px; overflow-y: auto; }
|
.brd-sig-scroll { max-height: 200px; overflow-y: auto; }
|
||||||
.brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
|
.brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
|
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
|
||||||
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
|
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||||
|
import type { EvaluationAllocationReport } from './backtest/BacktestAnalysisReportModal';
|
||||||
import {
|
import {
|
||||||
pct,
|
pct,
|
||||||
pctAbs,
|
pctAbs,
|
||||||
@@ -58,6 +59,9 @@ export interface BacktestDashboardProps {
|
|||||||
reportMode?: boolean;
|
reportMode?: boolean;
|
||||||
/** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */
|
/** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */
|
||||||
hideSignals?: boolean;
|
hideSignals?: boolean;
|
||||||
|
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
|
||||||
|
evaluationAllocations?: EvaluationAllocationReport[];
|
||||||
|
reportSignalFilterNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
||||||
@@ -70,8 +74,17 @@ export function BacktestDashboard({
|
|||||||
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
||||||
reportMode = false,
|
reportMode = false,
|
||||||
hideSignals = false,
|
hideSignals = false,
|
||||||
|
evaluationAllocations,
|
||||||
|
reportSignalFilterNote,
|
||||||
}: BacktestDashboardProps) {
|
}: BacktestDashboardProps) {
|
||||||
const a = analysis;
|
const [allocMode, setAllocMode] = useState<'full' | 'split'>('split');
|
||||||
|
|
||||||
|
const displayAnalysis = useMemo(() => {
|
||||||
|
if (!evaluationAllocations?.length) return analysis;
|
||||||
|
return evaluationAllocations.find(e => e.mode === allocMode)?.analysis ?? analysis;
|
||||||
|
}, [analysis, evaluationAllocations, allocMode]);
|
||||||
|
|
||||||
|
const a = displayAnalysis;
|
||||||
if (!a) {
|
if (!a) {
|
||||||
return (
|
return (
|
||||||
<div className="brd-empty-state">
|
<div className="brd-empty-state">
|
||||||
@@ -120,6 +133,49 @@ export function BacktestDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{evaluationAllocations && evaluationAllocations.length >= 2 && (
|
||||||
|
<div className="brd-row">
|
||||||
|
<div className="brd-card brd-card--full">
|
||||||
|
<SectionTitle icon="📋" title="매수 방식별 수익률 비교" />
|
||||||
|
{reportSignalFilterNote && (
|
||||||
|
<p className="brd-eval-filter-note">{reportSignalFilterNote}</p>
|
||||||
|
)}
|
||||||
|
<div className="brd-alloc-tabs">
|
||||||
|
{evaluationAllocations.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.mode}
|
||||||
|
type="button"
|
||||||
|
className={`brd-alloc-tab${allocMode === item.mode ? ' brd-alloc-tab--active' : ''}`}
|
||||||
|
onClick={() => setAllocMode(item.mode)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="brd-alloc-compare">
|
||||||
|
{evaluationAllocations.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.mode}
|
||||||
|
className={`brd-alloc-card${allocMode === item.mode ? ' brd-alloc-card--active' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setAllocMode(item.mode)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setAllocMode(item.mode); }}
|
||||||
|
>
|
||||||
|
<div className="brd-alloc-card-label">{item.label}</div>
|
||||||
|
<div className={`brd-alloc-card-return ${colorCls(item.analysis.totalReturnPct)}`}>
|
||||||
|
{pct(item.analysis.totalReturnPct)}
|
||||||
|
</div>
|
||||||
|
<div className="brd-alloc-card-sub">
|
||||||
|
최종 {wonFmt(item.analysis.finalEquity)} · 승률 {pctAbs(item.analysis.winRate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Row 1: KPI 카드 6개 ── */}
|
{/* ── Row 1: KPI 카드 6개 ── */}
|
||||||
<div className="brd-row">
|
<div className="brd-row">
|
||||||
<div className="brd-kpi-grid">
|
<div className="brd-kpi-grid">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ 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 { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport';
|
||||||
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
||||||
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
||||||
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
||||||
@@ -604,7 +604,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
timeframe: chartTimeframe,
|
timeframe: chartTimeframe,
|
||||||
symbol: market,
|
symbol: market,
|
||||||
strategyName: selectedStrategy.name,
|
strategyName: selectedStrategy.name,
|
||||||
settings: btSettings,
|
settings: { ...btSettings, positionMode: 'SIGNAL_ONLY' },
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
evaluationBarCount,
|
evaluationBarCount,
|
||||||
});
|
});
|
||||||
@@ -614,14 +614,16 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = buildChartBacktestReportModel({
|
const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000;
|
||||||
analysis: res.analysis ?? null,
|
const model = buildStrategyEvaluationReportModel({
|
||||||
stats: res.stats ?? null,
|
|
||||||
signals: res.signals,
|
signals: res.signals,
|
||||||
|
initialCapital,
|
||||||
strategyName: selectedStrategy.name ?? strategyLabel,
|
strategyName: selectedStrategy.name ?? strategyLabel,
|
||||||
symbol: market.replace(/^KRW-/, ''),
|
symbol: market.replace(/^KRW-/, ''),
|
||||||
timeframe: chartTimeframe,
|
timeframe: chartTimeframe,
|
||||||
barCount: bars.length,
|
barCount: bars.length,
|
||||||
|
firstBarClose: bars[0]?.close,
|
||||||
|
lastBarClose: bars[bars.length - 1]?.close,
|
||||||
});
|
});
|
||||||
if (!model) {
|
if (!model) {
|
||||||
window.alert('분석 데이터를 생성할 수 없습니다.');
|
window.alert('분석 데이터를 생성할 수 없습니다.');
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import { BacktestDashboard } from '../BacktestResultModal';
|
|||||||
import { printReportDocument } from '../../utils/printReportDocument';
|
import { printReportDocument } from '../../utils/printReportDocument';
|
||||||
import '../../styles/backtestReportPrint.css';
|
import '../../styles/backtestReportPrint.css';
|
||||||
|
|
||||||
|
export type EvaluationAllocationMode = 'full' | 'split';
|
||||||
|
|
||||||
|
export interface EvaluationAllocationReport {
|
||||||
|
mode: EvaluationAllocationMode;
|
||||||
|
label: string;
|
||||||
|
analysis: BacktestAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BacktestAnalysisReportModel {
|
export interface BacktestAnalysisReportModel {
|
||||||
analysis: BacktestAnalysis;
|
analysis: BacktestAnalysis;
|
||||||
signals: BacktestSignal[];
|
signals: BacktestSignal[];
|
||||||
@@ -18,6 +26,9 @@ export interface BacktestAnalysisReportModel {
|
|||||||
barCount: number;
|
barCount: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
reportKind: 'backtest' | 'live';
|
reportKind: 'backtest' | 'live';
|
||||||
|
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
|
||||||
|
evaluationAllocations?: EvaluationAllocationReport[];
|
||||||
|
reportSignalFilterNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -135,6 +146,8 @@ export default function BacktestAnalysisReportModal({ open, onClose, model }: Pr
|
|||||||
barCount={model.barCount}
|
barCount={model.barCount}
|
||||||
createdAt={model.createdAt}
|
createdAt={model.createdAt}
|
||||||
reportMode
|
reportMode
|
||||||
|
evaluationAllocations={model.evaluationAllocations}
|
||||||
|
reportSignalFilterNote={model.reportSignalFilterNote}
|
||||||
/>
|
/>
|
||||||
<footer className="btd-report-print-footer">
|
<footer className="btd-report-print-footer">
|
||||||
<p>본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.</p>
|
<p>본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.</p>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface TradeHistoryRow {
|
|||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
pnlPct?: number;
|
pnlPct?: number;
|
||||||
|
/** 매도 체결 시 실현 손익 (원) */
|
||||||
|
pnlAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUY_TYPES = new Set(['BUY']);
|
const BUY_TYPES = new Set(['BUY']);
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import type { BacktestAnalysis, BacktestSignal } from './backendApi';
|
||||||
|
import type {
|
||||||
|
BacktestAnalysisReportModel,
|
||||||
|
EvaluationAllocationReport,
|
||||||
|
} from '../components/backtest/BacktestAnalysisReportModal';
|
||||||
|
import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
|
||||||
|
import { repairUtf8Mojibake } from './textEncoding';
|
||||||
|
import { getKoreanName } from './marketNameCache';
|
||||||
|
import { toUpbitMarket } from './backtestUiUtils';
|
||||||
|
|
||||||
|
export type BuyAllocationMode = 'full' | 'split';
|
||||||
|
|
||||||
|
const BUY_TYPES = new Set<BacktestSignal['type']>(['BUY']);
|
||||||
|
const SELL_TYPES = new Set<BacktestSignal['type']>(['SELL', 'SHORT_EXIT', 'PARTIAL_SELL']);
|
||||||
|
|
||||||
|
function sortSignals(signals: BacktestSignal[]): BacktestSignal[] {
|
||||||
|
return [...signals].sort((a, b) => {
|
||||||
|
const t = a.time - b.time;
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return (a.barIndex ?? 0) - (b.barIndex ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최초 매수 이전 매도·최종 매도 이후 매수 시그널 제외 */
|
||||||
|
export function filterSignalsForEvaluationReport(signals: BacktestSignal[]): BacktestSignal[] {
|
||||||
|
const sorted = sortSignals(signals);
|
||||||
|
const firstBuyIdx = sorted.findIndex(s => BUY_TYPES.has(s.type));
|
||||||
|
if (firstBuyIdx < 0) return [];
|
||||||
|
|
||||||
|
let lastSellIdx = -1;
|
||||||
|
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||||
|
if (SELL_TYPES.has(sorted[i].type)) {
|
||||||
|
lastSellIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.filter((s, i) => {
|
||||||
|
if (i < firstBuyIdx && SELL_TYPES.has(s.type)) return false;
|
||||||
|
if (lastSellIdx >= 0 && i > lastSellIdx && BUY_TYPES.has(s.type)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buyCashFraction(buyIndexInCycle: number, mode: BuyAllocationMode): number | null {
|
||||||
|
if (mode === 'full') {
|
||||||
|
return buyIndexInCycle === 1 ? 1 : null;
|
||||||
|
}
|
||||||
|
if (buyIndexInCycle === 1) return 0.4;
|
||||||
|
if (buyIndexInCycle === 2) return 0.3;
|
||||||
|
if (buyIndexInCycle === 3) return 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluationSimulationResult {
|
||||||
|
curve: EquityPoint[];
|
||||||
|
trades: TradeHistoryRow[];
|
||||||
|
finalCash: number;
|
||||||
|
finalQty: number;
|
||||||
|
lastPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 필터링된 시그널 기준 포트폴리오 시뮬레이션 (매도는 전량) */
|
||||||
|
export function simulateEvaluationPortfolio(
|
||||||
|
signals: BacktestSignal[],
|
||||||
|
initialCapital: number,
|
||||||
|
symbol: string,
|
||||||
|
mode: BuyAllocationMode,
|
||||||
|
): EvaluationSimulationResult {
|
||||||
|
const sorted = sortSignals(signals);
|
||||||
|
const curve: EquityPoint[] = [];
|
||||||
|
const trades: TradeHistoryRow[] = [];
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return {
|
||||||
|
curve: [{ time: now, equity: initialCapital }],
|
||||||
|
trades: [],
|
||||||
|
finalCash: initialCapital,
|
||||||
|
finalQty: 0,
|
||||||
|
lastPrice: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cash = initialCapital;
|
||||||
|
let qty = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
let buyIndexInCycle = 0;
|
||||||
|
let tradeId = 0;
|
||||||
|
let lastPrice = sorted[0].price;
|
||||||
|
|
||||||
|
const markEquity = (time: number, price: number) => {
|
||||||
|
lastPrice = price;
|
||||||
|
const equity = qty > 0 ? qty * price + cash : cash;
|
||||||
|
const prev = curve[curve.length - 1];
|
||||||
|
if (!prev || prev.time !== time || Math.abs(prev.equity - equity) > 0.01) {
|
||||||
|
curve.push({ time, equity });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
markEquity(sorted[0].time, sorted[0].price);
|
||||||
|
|
||||||
|
for (const s of sorted) {
|
||||||
|
if (BUY_TYPES.has(s.type)) {
|
||||||
|
if (qty === 0) buyIndexInCycle = 0;
|
||||||
|
buyIndexInCycle += 1;
|
||||||
|
const frac = buyCashFraction(buyIndexInCycle, mode);
|
||||||
|
if (frac != null && cash > 0) {
|
||||||
|
const spend = cash * frac;
|
||||||
|
if (spend > 0 && s.price > 0) {
|
||||||
|
const buyQty = spend / s.price;
|
||||||
|
cash -= spend;
|
||||||
|
totalCost += spend;
|
||||||
|
qty += buyQty;
|
||||||
|
tradeId += 1;
|
||||||
|
trades.push({
|
||||||
|
id: tradeId,
|
||||||
|
time: s.time,
|
||||||
|
symbol,
|
||||||
|
side: 'buy',
|
||||||
|
price: s.price,
|
||||||
|
quantity: buyQty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markEquity(s.time, s.price);
|
||||||
|
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
||||||
|
const proceeds = qty * s.price;
|
||||||
|
const pnl = proceeds - totalCost;
|
||||||
|
const avgEntry = totalCost / qty;
|
||||||
|
const pnlPct = avgEntry > 0 ? (s.price - avgEntry) / avgEntry : 0;
|
||||||
|
cash += proceeds;
|
||||||
|
tradeId += 1;
|
||||||
|
trades.push({
|
||||||
|
id: tradeId,
|
||||||
|
time: s.time,
|
||||||
|
symbol,
|
||||||
|
side: 'sell',
|
||||||
|
price: s.price,
|
||||||
|
quantity: qty,
|
||||||
|
pnlPct,
|
||||||
|
pnlAmount: pnl,
|
||||||
|
});
|
||||||
|
qty = 0;
|
||||||
|
totalCost = 0;
|
||||||
|
buyIndexInCycle = 0;
|
||||||
|
markEquity(s.time, s.price);
|
||||||
|
} else {
|
||||||
|
markEquity(s.time, s.price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qty > 0) {
|
||||||
|
markEquity(sorted[sorted.length - 1].time, lastPrice);
|
||||||
|
} else if (curve.length === 1) {
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
markEquity(last.time, last.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { curve, trades, finalCash: cash, finalQty: qty, lastPrice };
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDrawdownFraction(curve: number[]): number {
|
||||||
|
if (curve.length < 2) return 0;
|
||||||
|
let peak = curve[0];
|
||||||
|
let mdd = 0;
|
||||||
|
for (const v of curve) {
|
||||||
|
if (v > peak) peak = v;
|
||||||
|
if (peak > 0) {
|
||||||
|
const dd = (peak - v) / peak;
|
||||||
|
if (dd > mdd) mdd = dd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxRunupFraction(curve: number[]): number {
|
||||||
|
if (curve.length < 2) return 0;
|
||||||
|
let trough = curve[0];
|
||||||
|
let runup = 0;
|
||||||
|
for (const v of curve) {
|
||||||
|
if (v < trough) trough = v;
|
||||||
|
if (trough > 0) {
|
||||||
|
const ru = (v - trough) / trough;
|
||||||
|
if (ru > runup) runup = ru;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharpeFromCurve(curve: number[]): number {
|
||||||
|
if (curve.length < 3) return 0;
|
||||||
|
const rets: number[] = [];
|
||||||
|
for (let i = 1; i < curve.length; i++) {
|
||||||
|
const prev = curve[i - 1];
|
||||||
|
if (prev > 0) rets.push((curve[i] - prev) / prev);
|
||||||
|
}
|
||||||
|
if (!rets.length) return 0;
|
||||||
|
const mean = rets.reduce((a, b) => a + b, 0) / rets.length;
|
||||||
|
const variance = rets.reduce((a, r) => a + (r - mean) ** 2, 0) / rets.length;
|
||||||
|
const std = Math.sqrt(variance);
|
||||||
|
if (std === 0) return mean > 0 ? 2.45 : 0;
|
||||||
|
return (mean / std) * Math.sqrt(252);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisFromSimulation(
|
||||||
|
sim: EvaluationSimulationResult,
|
||||||
|
initialCapital: number,
|
||||||
|
buyAndHoldReturnPct: number,
|
||||||
|
stillHolding: boolean,
|
||||||
|
): BacktestAnalysis {
|
||||||
|
const curveValues = sim.curve.map(p => p.equity);
|
||||||
|
const finalEquity = curveValues.length ? curveValues[curveValues.length - 1] : initialCapital;
|
||||||
|
const holdingsValue = sim.finalQty > 0 ? sim.finalQty * sim.lastPrice : 0;
|
||||||
|
const cashBalance = sim.finalCash;
|
||||||
|
|
||||||
|
const sells = sim.trades.filter(t => t.side === 'sell');
|
||||||
|
let wins = 0;
|
||||||
|
let grossProfit = 0;
|
||||||
|
let grossLoss = 0;
|
||||||
|
let breakEven = 0;
|
||||||
|
|
||||||
|
for (const t of sells) {
|
||||||
|
const pnl = t.pnlAmount ?? 0;
|
||||||
|
if (pnl > 0) {
|
||||||
|
wins += 1;
|
||||||
|
grossProfit += pnl;
|
||||||
|
} else if (pnl < 0) {
|
||||||
|
grossLoss += Math.abs(pnl);
|
||||||
|
} else {
|
||||||
|
breakEven += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = sells.length;
|
||||||
|
const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
|
||||||
|
const mdd = maxDrawdownFraction(curveValues);
|
||||||
|
const sharpe = sharpeFromCurve(curveValues);
|
||||||
|
const totalReturnPct = initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0;
|
||||||
|
const totalPnl = finalEquity - initialCapital;
|
||||||
|
const realizedFromSells = grossProfit - grossLoss; // net realized from closed rounds
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialCapital,
|
||||||
|
finalEquity,
|
||||||
|
analysisMethod: stillHolding ? 'MARK_TO_MARKET' : 'REALIZED_ONLY',
|
||||||
|
cashBalance: stillHolding ? cashBalance : finalEquity,
|
||||||
|
holdingsValue: stillHolding && holdingsValue > 0 ? holdingsValue : undefined,
|
||||||
|
realizedPnl: stillHolding ? realizedFromSells : totalPnl,
|
||||||
|
unrealizedPnl: stillHolding ? totalPnl - realizedFromSells : undefined,
|
||||||
|
totalReturnPct,
|
||||||
|
totalProfitLoss: finalEquity - initialCapital,
|
||||||
|
grossProfit,
|
||||||
|
grossLoss,
|
||||||
|
avgReturnPct: total > 0 ? totalReturnPct / total : 0,
|
||||||
|
profitLossRatio: Math.min(profitLossRatio, 99),
|
||||||
|
numberOfPositions: total,
|
||||||
|
numberOfWinning: wins,
|
||||||
|
numberOfLosing: Math.max(0, total - wins - breakEven),
|
||||||
|
numberOfBreakEven: breakEven,
|
||||||
|
winRate: total > 0 ? wins / total : 0,
|
||||||
|
maxDrawdownPct: mdd,
|
||||||
|
maxRunupPct: maxRunupFraction(curveValues),
|
||||||
|
sharpeRatio: sharpe,
|
||||||
|
sortinoRatio: 0,
|
||||||
|
calmarRatio: mdd > 0 ? totalReturnPct / mdd : 0,
|
||||||
|
valueAtRisk95: 0,
|
||||||
|
expectedShortfall: 0,
|
||||||
|
buyAndHoldReturnPct,
|
||||||
|
vsBuyAndHold: buyAndHoldReturnPct !== 0 ? totalReturnPct / buyAndHoldReturnPct : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStillHolding(signals: BacktestSignal[]): boolean {
|
||||||
|
let holding = false;
|
||||||
|
for (const s of sortSignals(signals)) {
|
||||||
|
if (BUY_TYPES.has(s.type)) holding = true;
|
||||||
|
else if (SELL_TYPES.has(s.type)) holding = false;
|
||||||
|
}
|
||||||
|
return holding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOCATION_LABELS: Record<BuyAllocationMode, string> = {
|
||||||
|
full: '일괄매수 (최초 매수 시 전액)',
|
||||||
|
split: '분할매수 (1차 40% · 2차 30% · 3차 잔여 전액)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 전략평가 화면 — 필터·시뮬레이션 기반 분석 레포트 모델 */
|
||||||
|
export function buildStrategyEvaluationReportModel(opts: {
|
||||||
|
signals: BacktestSignal[];
|
||||||
|
initialCapital: number;
|
||||||
|
strategyName: string;
|
||||||
|
symbol: string;
|
||||||
|
timeframe: string;
|
||||||
|
barCount: number;
|
||||||
|
createdAt?: string;
|
||||||
|
firstBarClose?: number;
|
||||||
|
lastBarClose?: number;
|
||||||
|
}): BacktestAnalysisReportModel | null {
|
||||||
|
const filtered = filterSignalsForEvaluationReport(opts.signals);
|
||||||
|
const stillHolding = isStillHolding(filtered);
|
||||||
|
|
||||||
|
const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0;
|
||||||
|
const lastClose = opts.lastBarClose ?? filtered[filtered.length - 1]?.price ?? 0;
|
||||||
|
const buyAndHoldReturnPct = firstClose > 0 ? (lastClose - firstClose) / firstClose : 0;
|
||||||
|
|
||||||
|
const modes: BuyAllocationMode[] = ['full', 'split'];
|
||||||
|
const evaluationAllocations: EvaluationAllocationReport[] = modes.map(mode => {
|
||||||
|
const sim = simulateEvaluationPortfolio(filtered, opts.initialCapital, opts.symbol, mode);
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
label: ALLOCATION_LABELS[mode],
|
||||||
|
analysis: buildAnalysisFromSimulation(sim, opts.initialCapital, buyAndHoldReturnPct, stillHolding),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = evaluationAllocations.find(a => a.mode === 'split') ?? evaluationAllocations[0];
|
||||||
|
if (!primary) return null;
|
||||||
|
|
||||||
|
const market = toUpbitMarket(opts.symbol);
|
||||||
|
return {
|
||||||
|
analysis: primary.analysis,
|
||||||
|
signals: filtered,
|
||||||
|
strategyName: repairUtf8Mojibake(opts.strategyName || '전략'),
|
||||||
|
symbol: opts.symbol,
|
||||||
|
symbolKo: repairUtf8Mojibake(getKoreanName(market)),
|
||||||
|
timeframe: opts.timeframe,
|
||||||
|
barCount: opts.barCount || 300,
|
||||||
|
createdAt: opts.createdAt,
|
||||||
|
reportKind: 'backtest',
|
||||||
|
evaluationAllocations,
|
||||||
|
reportSignalFilterNote:
|
||||||
|
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user