From 18949d9d79d345054b6f2e0d4f0d0824ee210131 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 25 Oct 2026 23:36:03 +0900 Subject: [PATCH] fix(fe): report funded USD portfolio results accurately --- AGENTS.md | 2 +- .../backtest/components/StatsSummary.tsx | 2 +- .../components/portfolio-form/DcaPreview.tsx | 2 +- .../portfolio-form/PortfolioTable.tsx | 2 +- .../__tests__/PortfolioTable.test.tsx | 2 +- .../model/types/backtest-result-types.ts | 7 +++--- .../__tests__/reportGenerator.results.test.ts | 23 ++++++++++++++++--- .../backtest/services/reportGenerator.ts | 15 +++++++----- 8 files changed, 38 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ceb2b84..b3c2781 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ ## Validation and deployment - Verify in Docker before declaring work complete: `docker build --target test .`, `sh scripts/audit-deps.sh`, and `docker build --target runtime .`. -- The test target runs ESLint, production and test TypeScript checks, and Vitest. Current baseline: 356 tests passed (2026-10-25). +- The test target runs ESLint, production and test TypeScript checks, and Vitest. Current baseline: 367 tests passed (2026-10-25). - GitHub Actions validates PRs and main/dev pushes. Only main publishes an ARM64 image and deploys its immutable digest through Tailscale and pinned OpenSSH. - production is the only OCI environment, with its deployment SSH key and OIDC identity. dev runs CI only; local development uses Compose. - Compose and deployment scripts live here; shared gateway files live outside the application repositories. Runtime secrets never belong in Git. diff --git a/src/features/backtest/components/StatsSummary.tsx b/src/features/backtest/components/StatsSummary.tsx index 629d4e7..b2a97d6 100644 --- a/src/features/backtest/components/StatsSummary.tsx +++ b/src/features/backtest/components/StatsSummary.tsx @@ -121,7 +121,7 @@ const StatsSummary: React.FC<{ stats: Record | null | undefined tone: mapVariantToTone( getStatVariant(numberValue(stats.sharpe_ratio ?? stats.Sharpe_Ratio), 'sharpe'), ), - description: '리스크 대비 성과 지표 (Sharpe)', + description: '일간 수익률 평균 ÷ 표준편차 × √252 · 무위험 수익률 0%', }, { label: '연간 변동성', diff --git a/src/features/backtest/components/portfolio-form/DcaPreview.tsx b/src/features/backtest/components/portfolio-form/DcaPreview.tsx index 60eb786..a40710b 100644 --- a/src/features/backtest/components/portfolio-form/DcaPreview.tsx +++ b/src/features/backtest/components/portfolio-form/DcaPreview.tsx @@ -24,7 +24,7 @@ export const DcaPreview: React.FC = ({ stock, startDate, endDat return (

- {frequencyLabel}: 총 {dcaPeriods}회, 회당 ${periodAmount.toLocaleString()} (총 ${totalAmount.toLocaleString()}) + {frequencyLabel}: 예정 {dcaPeriods}회, 회당 ${periodAmount.toLocaleString()} (예정 총액 ${totalAmount.toLocaleString()})

); }; diff --git a/src/features/backtest/components/portfolio-form/PortfolioTable.tsx b/src/features/backtest/components/portfolio-form/PortfolioTable.tsx index 5dcf812..3e55175 100644 --- a/src/features/backtest/components/portfolio-form/PortfolioTable.tsx +++ b/src/features/backtest/components/portfolio-form/PortfolioTable.tsx @@ -233,7 +233,7 @@ export const PortfolioTable: React.FC = ({ aria-label={`${index + 1}번째 종목 비중`} /> ) : ( - + {calculateWeight(stock)}% )} diff --git a/src/features/backtest/components/portfolio-form/__tests__/PortfolioTable.test.tsx b/src/features/backtest/components/portfolio-form/__tests__/PortfolioTable.test.tsx index f2d6235..34faac8 100644 --- a/src/features/backtest/components/portfolio-form/__tests__/PortfolioTable.test.tsx +++ b/src/features/backtest/components/portfolio-form/__tests__/PortfolioTable.test.tsx @@ -93,7 +93,7 @@ describe('PortfolioTable — 금액 모드', () => { const dcaWeight = ((1000 * periods) / total * 100).toFixed(1) expect(within(bodyRows()[0]!).getByText(`${dcaWeight}%`)).toBeInTheDocument() expect( - within(bodyRows()[0]!).getByText(new RegExp(`총 ${periods}회, 회당 \\$1,000`)) + within(bodyRows()[0]!).getByText(new RegExp(`예정 ${periods}회, 회당 \\$1,000`)) ).toBeInTheDocument() }) diff --git a/src/features/backtest/model/types/backtest-result-types.ts b/src/features/backtest/model/types/backtest-result-types.ts index 35246c7..e7d0151 100644 --- a/src/features/backtest/model/types/backtest-result-types.ts +++ b/src/features/backtest/model/types/backtest-result-types.ts @@ -10,12 +10,13 @@ export interface Stock { } // BE individual_returns 항목. return은 이미 백분율(8 = 8%), weight는 0~1 비율이다. -// 매수 후 보유 경로는 start_price/end_price를, 전략 경로는 initial_value/final_value를 싣는다. +// 금액·체결가는 모두 USD다. 매수 후 보유도 실제 납입액과 최종 평가액을 싣는다. export interface IndividualReturn { weight: number; return: number; - start_price?: number; - end_price?: number; + amount?: number; + start_price?: number | null; + end_price?: number | null; initial_value?: number; final_value?: number; } diff --git a/src/features/backtest/services/__tests__/reportGenerator.results.test.ts b/src/features/backtest/services/__tests__/reportGenerator.results.test.ts index 63241b1..a02fd81 100644 --- a/src/features/backtest/services/__tests__/reportGenerator.results.test.ts +++ b/src/features/backtest/services/__tests__/reportGenerator.results.test.ts @@ -38,7 +38,7 @@ describe('generateTextReport — 포트폴리오', () => { it('기간·자산가치·수익률·낙폭 등 주요 지표를 담는다', () => { expect(report).toContain(`백테스트 기간 : ${RESULT_DATES[0]} ~ ${RESULT_DATES[7]}`) - expect(report).toContain('초기 자본금 : $10,000') + expect(report).toContain('총 납입금 : $10,000') expect(report).toContain('최종 자산가치 : $10,700') expect(report).toContain('총 수익률 : 7.00%') expect(report).toContain('최대 낙폭(MDD) : -3.20%') @@ -48,7 +48,7 @@ describe('generateTextReport — 포트폴리오', () => { it('개별 종목 수익률은 BE가 준 백분율을 그대로, 비중은 비율을 퍼센트로 보여 준다', () => { expect(report).toContain('■ 개별 종목 수익률') expect(report).toMatch(/AAPL\s+- 수익률: 8\.00%\s+\|\s+비중: 60\.00%/) - expect(report).toMatch(/시작가: \$125\.00\s+\|\s+종료가: \$135\.00/) + expect(report).toMatch(/평균 매수가\(USD\): \$125\.00\s+\|\s+종료가\(USD\): \$135\.00/) }) it('환율 데이터가 없으면 환율 섹션을 넣지 않는다', () => { @@ -83,6 +83,23 @@ describe('generateTextReport — 포트폴리오', () => { }) }) +describe('실제 USD 체결 결과 리포트', () => { + it('환산된 가격·실제 납입액·최종 평가액과 지표 정의를 함께 표시한다', () => { + const data = makePortfolioData({ + individual_returns: { + KRWX: { weight: 1, return: -50, amount: 1000, final_value: 500, start_price: 100, end_price: 50 }, + }, + }); + const text = generateTextReport(data, true); + expect(text).toContain('평균 매수가(USD): $100.00'); + expect(text).toContain('종료가(USD): $50.00'); + expect(text).toContain('투자금: $1000.00 | 최종 가치: $500.00'); + expect(text).toContain('수수료·환율 반영'); + expect(text).toContain('무위험 수익률 0%'); + expect(generateCSVReport(data, true)).toContain('평균 매수가(USD),종료가(USD)'); + }); +}); + describe('generateTextReport — 단일 종목', () => { it('기본 정보·지표·거래 내역을 담는다', () => { const text = generateTextReport(makeSingleStockData(), false) @@ -162,7 +179,7 @@ describe('generateCSVReport — 포트폴리오', () => { expect(csv).toContain(`백테스트 시작일,${RESULT_DATES[0]}`) expect(csv).toContain('총 수익률,7.00%') expect(csv).toContain('최대 낙폭(MDD),-3.20%') - expect(csv).toContain('종목,수익률(%),비중(%),시작가($),종료가($)') + expect(csv).toContain('종목,수익률(%),비중(%),평균 매수가(USD),종료가(USD)') expect(csv).toContain('AAPL,8.00,60.00,125.00,135.00') }) diff --git a/src/features/backtest/services/reportGenerator.ts b/src/features/backtest/services/reportGenerator.ts index 250510c..5146ab7 100644 --- a/src/features/backtest/services/reportGenerator.ts +++ b/src/features/backtest/services/reportGenerator.ts @@ -73,7 +73,7 @@ export const generateTextReport = (data: BacktestResultData, isPortfolio: boolea lines.push('-'.repeat(80)); lines.push(` 백테스트 기간 : ${stats.Start} ~ ${stats.End}`); lines.push(` 운용 기간 : ${stats.Duration}`); - lines.push(` 초기 자본금 : $${stats.Initial_Value.toLocaleString()}`); + lines.push(` 총 납입금 : $${stats.Initial_Value.toLocaleString()}`); lines.push(` 최종 자산가치 : $${stats.Final_Value.toLocaleString()}`); lines.push(` 최고 자산가치 : $${stats.Peak_Value.toLocaleString()}`); lines.push(''); @@ -82,6 +82,7 @@ export const generateTextReport = (data: BacktestResultData, isPortfolio: boolea lines.push(` 연간 변동성 : ${stats.Annual_Volatility.toFixed(2)}%`); lines.push(''); lines.push(` 샤프 비율 : ${stats.Sharpe_Ratio.toFixed(2)}`); + lines.push(' 산식: 일간 시간가중 수익률의 평균 / 표준편차 × √252 (무위험 수익률 0%)'); lines.push(` 최대 낙폭(MDD) : ${stats.Max_Drawdown.toFixed(2)}%`); lines.push(` 평균 낙폭 : ${stats.Avg_Drawdown.toFixed(2)}%`); lines.push(''); @@ -99,15 +100,17 @@ export const generateTextReport = (data: BacktestResultData, isPortfolio: boolea if ('individual_returns' in data && data.individual_returns) { lines.push('■ 개별 종목 수익률'); + lines.push(' USD 기준 · 수수료·환율 반영 · 리밸런싱 자금 이동은 손익에서 제외'); lines.push('-'.repeat(80)); Object.entries(data.individual_returns).forEach(([symbol, info]) => { // return은 BE가 이미 백분율로 준다. weight만 0~1 비율이라 100을 곱한다. lines.push(` ${symbol.padEnd(10)} - 수익률: ${percentOrNA(info.return)} | 비중: ${(info.weight * 100).toFixed(2)}%`); if (info.start_price !== undefined || info.end_price !== undefined) { - lines.push(` 시작가: ${dollarOrNA(info.start_price)} | 종료가: ${dollarOrNA(info.end_price)}`); - } else if (info.initial_value !== undefined || info.final_value !== undefined) { + lines.push(` 평균 매수가(USD): ${dollarOrNA(info.start_price)} | 종료가(USD): ${dollarOrNA(info.end_price)}`); + } + if (info.initial_value !== undefined || info.final_value !== undefined) { // 전략 포트폴리오 경로는 가격 대신 투자 금액과 최종 가치를 준다. - lines.push(` 투자금: ${dollarOrNA(info.initial_value)} | 최종 가치: ${dollarOrNA(info.final_value)}`); + lines.push(` 투자금: ${dollarOrNA(info.initial_value ?? info.amount)} | 최종 가치: ${dollarOrNA(info.final_value)}`); } }); lines.push(''); @@ -219,7 +222,7 @@ export const generateCSVReport = (data: BacktestResultData, isPortfolio: boolean csvRows.push(`백테스트 시작일,${stats.Start}`); csvRows.push(`백테스트 종료일,${stats.End}`); csvRows.push(`운용 기간,${stats.Duration}`); - csvRows.push(`초기 자본금,$${stats.Initial_Value.toLocaleString()}`); + csvRows.push(`총 납입금,$${stats.Initial_Value.toLocaleString()}`); csvRows.push(`최종 자산가치,$${stats.Final_Value.toLocaleString()}`); csvRows.push(`최고 자산가치,$${stats.Peak_Value.toLocaleString()}`); csvRows.push(`총 수익률,${stats.Total_Return.toFixed(2)}%`); @@ -242,7 +245,7 @@ export const generateCSVReport = (data: BacktestResultData, isPortfolio: boolean if ('individual_returns' in data && data.individual_returns) { csvRows.push('개별 종목 수익률'); - csvRows.push('종목,수익률(%),비중(%),시작가($),종료가($)'); + csvRows.push('종목,수익률(%),비중(%),평균 매수가(USD),종료가(USD)'); Object.entries(data.individual_returns).forEach(([symbol, info]) => { csvRows.push(`${symbol},${fixedOrNA(info.return)},${(info.weight * 100).toFixed(2)},${fixedOrNA(info.start_price)},${fixedOrNA(info.end_price)}`); });