diff --git a/src/_BacktestingPage/components/AssetAllocation.tsx b/src/_BacktestingPage/components/AssetAllocation.tsx index 21578c1..b2cbb55 100644 --- a/src/_BacktestingPage/components/AssetAllocation.tsx +++ b/src/_BacktestingPage/components/AssetAllocation.tsx @@ -1,12 +1,16 @@ +import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import type { Asset } from "@/_BacktestingPage/types/backtestFormType"; import AssetItem from "@/_BacktestingPage/components/AssetItem"; import { v4 as uuidv4 } from "uuid"; import WeightSummary from "@/_BacktestingPage/components/WeightSummary"; + type AssetAllocationProps = { assets: Asset[]; setAssets: React.Dispatch>; totalWeight: number; }; + const AssetAllocation = ({ assets, setAssets, totalWeight }: AssetAllocationProps) => { const handleAddAsset = () => { setAssets([...assets, { id: uuidv4(), name: "", ticker: "", weight: 0 }]); @@ -25,29 +29,36 @@ const AssetAllocation = ({ assets, setAssets, totalWeight }: AssetAllocationProp }; return ( -
-
- 자산 설정 -
-
- {assets.map((asset, index) => ( - handleDeleteAsset(asset.id)} - /> - ))} -
- - -
+ <> + + 자산 설정 + + +
+ {assets.map((asset, index) => ( + handleDeleteAsset(asset.id)} + /> + ))} +
+
+ +
+
+ +
+
+ ); }; diff --git a/src/_BacktestingPage/components/AssetItem.tsx b/src/_BacktestingPage/components/AssetItem.tsx index 96695fd..8ee4ea0 100644 --- a/src/_BacktestingPage/components/AssetItem.tsx +++ b/src/_BacktestingPage/components/AssetItem.tsx @@ -62,13 +62,11 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => }, []); return ( -
-
- 자산 {AssetIndex + 1} -
+
+
자산 {AssetIndex + 1}
{ setQuery(e.target.value); @@ -87,21 +85,22 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => /> {isDropdownOpen && searchResults.length > 0 && ( -
+
{searchResults.map((item) => (
handleSelect(item)} > - {item.name} ({item.ticker}) + {item.name} + ({item.ticker})
))}
)} { const newWeight = Math.max(0, Math.min(100, Number(e.target.value))); @@ -111,7 +110,7 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => /> {AssetIndex !== 0 ? ( */} - - -
+
+ 만원 +
+
+ + + + )} + /> +
+
+
리밸런싱 주기
+ ( + + + + + + + 매월 + + + + + + 분기별 + + + + + + 매년 + + + + + + + )} + /> +
+ + + + ); }; diff --git a/src/_BacktestingPage/components/BacktestResult.tsx b/src/_BacktestingPage/components/BacktestResult.tsx new file mode 100644 index 0000000..593b1dc --- /dev/null +++ b/src/_BacktestingPage/components/BacktestResult.tsx @@ -0,0 +1,53 @@ +import type { BacktestResult as BacktestResultType } from "@/_BacktestingPage/types/backtestFormType"; +import BacktestSummation from "./BacktestSummation"; +import BacktestResultTable from "./BacktestResultTable"; +import BacktestChart from "./BacktestChart"; +import { formatNumber } from "@/lib/utils"; + +interface BacktestResultProps { + data: BacktestResultType; +} + +const BacktestResult = ({ data }: BacktestResultProps) => { + // 수익률 포맷팅 함수 + const formatPercentage = (value: number): string => { + return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`; + }; + + return ( +
+

백테스트 결과

+ + + + {/* 차트 섹션 */} +
+ {/* 월별 자산 추이 */} + `${formatNumber(value)}원`} + /> + + {/* 월별 수익률 */} + + + {/* 월별 최대 낙폭 */} + +
+
+ ); +}; + +export default BacktestResult; diff --git a/src/_BacktestingPage/components/BacktestResultTable.tsx b/src/_BacktestingPage/components/BacktestResultTable.tsx new file mode 100644 index 0000000..2c1e227 --- /dev/null +++ b/src/_BacktestingPage/components/BacktestResultTable.tsx @@ -0,0 +1,64 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { BacktestResult as BacktestResultType } from "@/_BacktestingPage/types/backtestFormType"; +import { + createComparisonRows, + isPortfolioBetter, +} from "@/_BacktestingPage/utils/backtestComparisons"; + +interface BacktestResultTableProps { + data: BacktestResultType; +} + +const BacktestResultTable = ({ data }: BacktestResultTableProps) => { + const comparisonRows = createComparisonRows(data); + + return ( + + + 벤치마크 비교 + + +
+ + + + + + + + + + + {comparisonRows.map((row, index) => { + const isBetter = + row.label === "초기 자본" + ? false + : isPortfolioBetter(row.portfolio, row.kospi, row.kosdaq, row.higherIsBetter); + + return ( + + + + + + + ); + })} + +
지표KOSPIKOSDAQ나의 포트폴리오
{row.label}{row.format(row.kospi)}{row.format(row.kosdaq)} + {row.format(row.portfolio)} +
+
+
+
+ ); +}; + +export default BacktestResultTable; diff --git a/src/_BacktestingPage/components/BacktestSummation.tsx b/src/_BacktestingPage/components/BacktestSummation.tsx new file mode 100644 index 0000000..c60c323 --- /dev/null +++ b/src/_BacktestingPage/components/BacktestSummation.tsx @@ -0,0 +1,100 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumber } from "@/lib/utils"; +import type { PortfolioSummary } from "@/_BacktestingPage/types/backtestFormType"; +import { + formatPercentage, + formatRatio, + calculateReturnRate, +} from "@/_BacktestingPage/utils/backtestFormatters"; + +interface BacktestSummationProps { + portfolioSummary: PortfolioSummary; +} + +const BacktestSummation = ({ portfolioSummary }: BacktestSummationProps) => { + const returnRate = calculateReturnRate( + portfolioSummary.initialCapital, + portfolioSummary.finalCapital + ); + + return ( + + + {portfolioSummary.portfolioName} + + +
+ {/* 초기 자본 */} +
+
초기 자본
+
+ {formatNumber(portfolioSummary.initialCapital)}원 +
+
+ + {/* 최종 자본 */} +
+
최종 자본
+
+ {formatNumber(portfolioSummary.finalCapital)}원 +
+
+ + {/* 수익률 */} +
+
수익률
+
= 0 ? "text-green-400" : "text-red-400"}`} + > + {formatPercentage(returnRate)} +
+
+ + {/* CAGR */} +
+
CAGR
+
= 0 ? "text-green-400" : "text-red-400"}`} + > + {formatPercentage(portfolioSummary.cagr)} +
+
+ + {/* 최대 낙폭 */} +
+
최대 낙폭
+
+ {formatPercentage(portfolioSummary.maxDrawdown)} +
+
+ + {/* 변동성 */} +
+
변동성
+
+ {formatPercentage(portfolioSummary.volatility)} +
+
+ + {/* 샤프 비율 */} +
+
샤프 비율
+
+ {formatRatio(portfolioSummary.sharpeRatio)} +
+
+ + {/* 소르티노 비율 */} +
+
소르티노 비율
+
+ {formatRatio(portfolioSummary.sortinoRatio)} +
+
+
+
+
+ ); +}; + +export default BacktestSummation; diff --git a/src/_BacktestingPage/components/Notice.tsx b/src/_BacktestingPage/components/Notice.tsx index fdcd690..9bf8bb3 100644 --- a/src/_BacktestingPage/components/Notice.tsx +++ b/src/_BacktestingPage/components/Notice.tsx @@ -1,15 +1,20 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { notices } from "@/_BacktestingPage/datas/notice"; const Notice = () => { return ( -
-
Notice
- -
+ + + Notice + + +
    + {notices.map((notice, idx) => ( +
  • {notice}
  • + ))} +
+
+
); }; diff --git a/src/_BacktestingPage/components/StartBacktestButton.tsx b/src/_BacktestingPage/components/StartBacktestButton.tsx index a474ad4..014cc5e 100644 --- a/src/_BacktestingPage/components/StartBacktestButton.tsx +++ b/src/_BacktestingPage/components/StartBacktestButton.tsx @@ -1,19 +1,25 @@ +import { Button } from "@/components/ui/button"; + type StartBacktestButtonProps = { handleSubmit: () => void; disabled: boolean; }; + const StartBacktestButton = ({ handleSubmit, disabled }: StartBacktestButtonProps) => { return ( -
- +
); }; diff --git a/src/_BacktestingPage/components/WeightSummary.tsx b/src/_BacktestingPage/components/WeightSummary.tsx index 12cb85d..d0a8d3b 100644 --- a/src/_BacktestingPage/components/WeightSummary.tsx +++ b/src/_BacktestingPage/components/WeightSummary.tsx @@ -6,12 +6,14 @@ const WeightSummary = ({ totalWeight }: WeightSummaryProps) => { const isValid = totalWeight === 100; return ( -
-
+
+
현재 비중 합계: {totalWeight}%
{!isValid && ( -
비중의 합이 100%가 되어야 합니다.
+
+ 비중의 합이 100%가 되어야 합니다. +
)}
); diff --git a/src/_BacktestingPage/types/backtestFormType.ts b/src/_BacktestingPage/types/backtestFormType.ts index e6c49c0..e98769c 100644 --- a/src/_BacktestingPage/types/backtestFormType.ts +++ b/src/_BacktestingPage/types/backtestFormType.ts @@ -26,3 +26,29 @@ export type SearchResult = { ticker: string; name: string; }; + +// 백테스팅 결과 타입 +export interface PortfolioSummary { + portfolioName: string; + initialCapital: number; + finalCapital: number; + cagr: number; + maxDrawdown: number; + volatility: number; + sharpeRatio: number; + sortinoRatio: number; +} + +export interface MonthlyData { + date: string; + value: number; +} + +export interface BacktestResult { + kospiSummary: PortfolioSummary; + kosdaqSummary: PortfolioSummary; + portfolioSummary: PortfolioSummary; + monthlyDrawdowns: MonthlyData[]; + monthlyAssets: MonthlyData[]; + monthlyReturns: MonthlyData[]; +} diff --git a/src/_BacktestingPage/utils/backtestComparisons.ts b/src/_BacktestingPage/utils/backtestComparisons.ts new file mode 100644 index 0000000..482e806 --- /dev/null +++ b/src/_BacktestingPage/utils/backtestComparisons.ts @@ -0,0 +1,122 @@ +import type { BacktestResult as BacktestResultType } from "@/_BacktestingPage/types/backtestFormType"; +import { + calculateReturnRate, + formatPercentage, + formatRatio, + formatCapital, +} from "./backtestFormatters"; + +/** + * 포트폴리오가 벤치마크보다 더 좋은지 판단 + */ +export const isPortfolioBetter = ( + portfolioValue: number, + kospiValue: number, + kosdaqValue: number, + higherIsBetter: boolean +): boolean => { + if (higherIsBetter) { + return portfolioValue > kospiValue && portfolioValue > kosdaqValue; + } else { + return portfolioValue < kospiValue && portfolioValue < kosdaqValue; + } +}; + +/** + * 비교 테이블 행 데이터 타입 + */ +export interface ComparisonRow { + label: string; + kospi: number; + kosdaq: number; + portfolio: number; + format: (val: number) => string; + higherIsBetter: boolean; +} + +/** + * 백테스팅 결과로부터 비교 테이블 행 데이터 생성 + */ +export const createComparisonRows = (data: BacktestResultType): ComparisonRow[] => { + const { kospiSummary, kosdaqSummary, portfolioSummary } = data; + + const kospiReturnRate = calculateReturnRate( + kospiSummary.initialCapital, + kospiSummary.finalCapital + ); + const kosdaqReturnRate = calculateReturnRate( + kosdaqSummary.initialCapital, + kosdaqSummary.finalCapital + ); + const portfolioReturnRate = calculateReturnRate( + portfolioSummary.initialCapital, + portfolioSummary.finalCapital + ); + + return [ + { + label: "초기 자본", + kospi: kospiSummary.initialCapital, + kosdaq: kosdaqSummary.initialCapital, + portfolio: portfolioSummary.initialCapital, + format: formatCapital, + higherIsBetter: false, // 초기 자본은 비교 의미 없음 + }, + { + label: "최종 자본", + kospi: kospiSummary.finalCapital, + kosdaq: kosdaqSummary.finalCapital, + portfolio: portfolioSummary.finalCapital, + format: formatCapital, + higherIsBetter: true, + }, + { + label: "수익률", + kospi: kospiReturnRate, + kosdaq: kosdaqReturnRate, + portfolio: portfolioReturnRate, + format: formatPercentage, + higherIsBetter: true, + }, + { + label: "CAGR", + kospi: kospiSummary.cagr, + kosdaq: kosdaqSummary.cagr, + portfolio: portfolioSummary.cagr, + format: formatPercentage, + higherIsBetter: true, + }, + { + label: "최대 낙폭", + kospi: kospiSummary.maxDrawdown, + kosdaq: kosdaqSummary.maxDrawdown, + portfolio: portfolioSummary.maxDrawdown, + format: formatPercentage, + higherIsBetter: false, // 낮을수록 좋음 + }, + { + label: "변동성", + kospi: kospiSummary.volatility, + kosdaq: kosdaqSummary.volatility, + portfolio: portfolioSummary.volatility, + format: formatPercentage, + higherIsBetter: false, // 낮을수록 좋음 + }, + { + label: "샤프 비율", + kospi: kospiSummary.sharpeRatio, + kosdaq: kosdaqSummary.sharpeRatio, + portfolio: portfolioSummary.sharpeRatio, + format: formatRatio, + higherIsBetter: true, + }, + { + label: "소르티노 비율", + kospi: kospiSummary.sortinoRatio, + kosdaq: kosdaqSummary.sortinoRatio, + portfolio: portfolioSummary.sortinoRatio, + format: formatRatio, + higherIsBetter: true, + }, + ]; +}; diff --git a/src/_BacktestingPage/utils/backtestFormSchema.ts b/src/_BacktestingPage/utils/backtestFormSchema.ts index d289329..4d72bb9 100644 --- a/src/_BacktestingPage/utils/backtestFormSchema.ts +++ b/src/_BacktestingPage/utils/backtestFormSchema.ts @@ -1,6 +1,10 @@ import { z } from "zod"; +import { differenceInMonths, differenceInYears } from "date-fns"; + const MIN_DATE = new Date("1900-01-01"); const TODAY = new Date(); +const MIN_MONTHS_DIFF = 3; // 최소 3개월 +const MAX_YEARS_DIFF = 10; // 최대 10년 export const backtestFormSchema = z .object({ @@ -19,5 +23,25 @@ export const backtestFormSchema = z .refine((data) => data.startDate <= data.endDate, { message: "시작일은 종료일보다 이전이어야 합니다.", path: ["startDate"], - }); + }) + .refine( + (data) => { + const monthsDiff = differenceInMonths(data.endDate, data.startDate); + return monthsDiff >= MIN_MONTHS_DIFF; + }, + { + message: `시작일은 종료일보다 최소 ${MIN_MONTHS_DIFF}개월 전이어야 합니다.`, + path: ["startDate"], + } + ) + .refine( + (data) => { + const yearsDiff = differenceInYears(data.endDate, data.startDate); + return yearsDiff <= MAX_YEARS_DIFF; + }, + { + message: `시작일은 종료일보다 최대 ${MAX_YEARS_DIFF}년 전까지 가능합니다.`, + path: ["startDate"], + } + ); export type BacktestFormSchema = z.infer; diff --git a/src/_BacktestingPage/utils/backtestFormatters.ts b/src/_BacktestingPage/utils/backtestFormatters.ts new file mode 100644 index 0000000..c1ae42f --- /dev/null +++ b/src/_BacktestingPage/utils/backtestFormatters.ts @@ -0,0 +1,29 @@ +import { formatNumber } from "@/lib/utils"; + +/** + * 백분율 포맷팅 (소수점 2자리, + 기호 포함) + */ +export const formatPercentage = (value: number): string => { + return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`; +}; + +/** + * 비율 포맷팅 (소수점 2자리) + */ +export const formatRatio = (value: number): string => { + return value.toFixed(2); +}; + +/** + * 자본 포맷팅 (천 단위 구분자 + 원) + */ +export const formatCapital = (value: number): string => { + return `${formatNumber(value)}원`; +}; + +/** + * 수익률 계산 + */ +export const calculateReturnRate = (initialCapital: number, finalCapital: number): number => { + return ((finalCapital - initialCapital) / initialCapital) * 100; +}; diff --git a/src/components/Title.tsx b/src/components/Title.tsx index 3ba0d9d..3587f09 100644 --- a/src/components/Title.tsx +++ b/src/components/Title.tsx @@ -3,7 +3,7 @@ type TitleProps = { }; const Title = ({ title }: TitleProps) => { - return
{title}
; + return

{title}

; }; export default Title; diff --git a/src/components/ui/datepicker.tsx b/src/components/ui/datepicker.tsx index ed4269f..7786ac3 100644 --- a/src/components/ui/datepicker.tsx +++ b/src/components/ui/datepicker.tsx @@ -26,7 +26,7 @@ export function DatePicker< + + +
+ ); }; export default PortfolioPage;