Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/features/backtest/components/StatsSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const StatsSummary: React.FC<{ stats: Record<string, unknown> | null | undefined
tone: mapVariantToTone(
getStatVariant(numberValue(stats.sharpe_ratio ?? stats.Sharpe_Ratio), 'sharpe'),
),
description: '리스크 대비 성과 지표 (Sharpe)',
description: '일간 수익률 평균 ÷ 표준편차 × √252 · 무위험 수익률 0%',
},
{
label: '연간 변동성',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const DcaPreview: React.FC<DcaPreviewProps> = ({ stock, startDate, endDat

return (
<p className={`${TEXT_STYLES.captionSmall} mt-1`}>
{frequencyLabel}: 총 {dcaPeriods}회, 회당 ${periodAmount.toLocaleString()} (총 ${totalAmount.toLocaleString()})
{frequencyLabel}: 예정 {dcaPeriods}회, 회당 ${periodAmount.toLocaleString()} (예정 총액 ${totalAmount.toLocaleString()})
</p>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export const PortfolioTable: React.FC<PortfolioTableProps> = ({
aria-label={`${index + 1}번째 종목 비중`}
/>
) : (
<span title="DCA를 포함한 총 투자금액 비율로 자동 계산됨. 비중을 직접 입력하려면 비중 기반 모드로 전환하세요.">
<span title="DCA 예정 납입액을 포함한 투자금액 비율로 자동 계산됨. 비중을 직접 입력하려면 비중 기반 모드로 전환하세요.">
{calculateWeight(stock)}%
</span>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
7 changes: 4 additions & 3 deletions src/features/backtest/model/types/backtest-result-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%')
Expand All @@ -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('환율 데이터가 없으면 환율 섹션을 넣지 않는다', () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
})

Expand Down
15 changes: 9 additions & 6 deletions src/features/backtest/services/reportGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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('');
Expand All @@ -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('');
Expand Down Expand Up @@ -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)}%`);
Expand All @@ -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)}`);
});
Expand Down