From 0fdf41ab02c756052e71d88bdc1f70aab89e6e45 Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 22:41:05 +0900 Subject: [PATCH 01/13] =?UTF-8?q?style:=20Empty=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/empty.tsx | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/components/ui/empty.tsx diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..71e8ddb --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,94 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "flex justify-center items-center mb-2 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; From e21d555aa1d61bbabb115c3e6ebe7ae221a0846d Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 22:41:21 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8D=BC?= =?UTF-8?q?=EB=B8=94=EB=A6=AC=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/PortfolioPage.tsx | 43 ++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/pages/PortfolioPage.tsx b/src/pages/PortfolioPage.tsx index 2527888..c678e26 100644 --- a/src/pages/PortfolioPage.tsx +++ b/src/pages/PortfolioPage.tsx @@ -1,5 +1,46 @@ +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { ArrowRight, ChartNoAxesCombined } from "lucide-react"; +import { Link } from "react-router-dom"; + const PortfolioPage = () => { - return

Portfolio

; + return ( + // TODO: 데이터가 있다면 바로가기 리스트를 보여준다. +
+ + + + + + 포트폴리오가 없습니다 :( + +

아직 수행한 백테스팅이 없어요.

+

아래 버튼을 통해 백테스팅을 수행해보세요.

+
+
+ + + +
+
+ ); }; export default PortfolioPage; From 9d4983cc8cada9f295c4d4d3435fefbbb8230eff Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:09:11 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20=EB=AA=A9=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../types/backtestFormType.ts | 26 ++++++++++++++++ .../utils/backtestFormatters.ts | 30 +++++++++++++++++++ src/pages/BacktestingPage.tsx | 3 ++ 3 files changed, 59 insertions(+) create mode 100644 src/_BacktestingPage/utils/backtestFormatters.ts 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/backtestFormatters.ts b/src/_BacktestingPage/utils/backtestFormatters.ts new file mode 100644 index 0000000..10eb2dc --- /dev/null +++ b/src/_BacktestingPage/utils/backtestFormatters.ts @@ -0,0 +1,30 @@ +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/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index f33143e..e4381e5 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -12,6 +12,8 @@ import { import { useState, useMemo } from "react"; import { mapToBacktestRequest } from "@/_BacktestingPage/utils/mapToRequest"; import { v4 as uuidv4 } from "uuid"; +import BacktestResult from "@/_BacktestingPage/components/BacktestResult"; +import { MOCK_BACKTEST_RESULT } from "@/constants/mockBacktest"; const BacktestingPage = () => { const [assets, setAssets] = useState([{ id: uuidv4(), name: "", ticker: "", weight: 0 }]); @@ -78,6 +80,7 @@ ${requestData.assets handleSubmit={handleSubmit} disabled={totalWeight !== 100} > +
); }; From 64307b49f9fa23fe26a33bd0b3fb0f9d199848d0 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:09:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/mockBacktest.ts | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/constants/mockBacktest.ts diff --git a/src/constants/mockBacktest.ts b/src/constants/mockBacktest.ts new file mode 100644 index 0000000..29a6fab --- /dev/null +++ b/src/constants/mockBacktest.ts @@ -0,0 +1,77 @@ +import type { BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; + +// 목 데이터 - 전체 백테스팅 결과 +export const MOCK_BACKTEST_RESULT: BacktestResult = { + kospiSummary: { + portfolioName: "KOSPI", + initialCapital: 100000000, + finalCapital: 135000000, + cagr: 6.2, + maxDrawdown: -18.5, + volatility: 18.3, + sharpeRatio: 0.95, + sortinoRatio: 1.12, + }, + kosdaqSummary: { + portfolioName: "KOSDAQ", + initialCapital: 100000000, + finalCapital: 142000000, + cagr: 7.1, + maxDrawdown: -22.3, + volatility: 24.5, + sharpeRatio: 0.78, + sortinoRatio: 0.92, + }, + portfolioSummary: { + portfolioName: "나의 포트폴리오", + initialCapital: 100000000, + finalCapital: 158000000, + cagr: 9.2, + maxDrawdown: -14.8, + volatility: 16.7, + sharpeRatio: 1.58, + sortinoRatio: 1.95, + }, + monthlyDrawdowns: [ + { date: "2024-01-31", value: -2.5 }, + { date: "2024-02-29", value: -4.2 }, + { date: "2024-03-31", value: -3.1 }, + { date: "2024-04-30", value: -5.8 }, + { date: "2024-05-31", value: -8.3 }, + { date: "2024-06-30", value: -12.1 }, + { date: "2024-07-31", value: -14.8 }, + { date: "2024-08-31", value: -11.5 }, + { date: "2024-09-30", value: -9.2 }, + { date: "2024-10-31", value: -7.4 }, + { date: "2024-11-30", value: -5.1 }, + { date: "2024-12-31", value: -3.2 }, + ], + monthlyAssets: [ + { date: "2024-01-31", value: 101500000 }, + { date: "2024-02-29", value: 103200000 }, + { date: "2024-03-31", value: 105800000 }, + { date: "2024-04-30", value: 108500000 }, + { date: "2024-05-31", value: 112300000 }, + { date: "2024-06-30", value: 115600000 }, + { date: "2024-07-31", value: 118200000 }, + { date: "2024-08-31", value: 122500000 }, + { date: "2024-09-30", value: 128300000 }, + { date: "2024-10-31", value: 134700000 }, + { date: "2024-11-30", value: 145200000 }, + { date: "2024-12-31", value: 158000000 }, + ], + monthlyReturns: [ + { date: "2024-01-31", value: 1.5 }, + { date: "2024-02-29", value: 1.7 }, + { date: "2024-03-31", value: 2.5 }, + { date: "2024-04-30", value: 2.6 }, + { date: "2024-05-31", value: 3.5 }, + { date: "2024-06-30", value: 2.9 }, + { date: "2024-07-31", value: 2.3 }, + { date: "2024-08-31", value: 3.6 }, + { date: "2024-09-30", value: 4.7 }, + { date: "2024-10-31", value: 5.0 }, + { date: "2024-11-30", value: 7.8 }, + { date: "2024-12-31", value: 8.8 }, + ], +}; From 0d5b2a5d585dc120bdda86b90c0907ea956ad272 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:10:04 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EA=B2=B0=EA=B3=BC=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90,=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/backtestComparisons.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/_BacktestingPage/utils/backtestComparisons.ts diff --git a/src/_BacktestingPage/utils/backtestComparisons.ts b/src/_BacktestingPage/utils/backtestComparisons.ts new file mode 100644 index 0000000..c00fdc7 --- /dev/null +++ b/src/_BacktestingPage/utils/backtestComparisons.ts @@ -0,0 +1,118 @@ +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, + }, + ]; +}; + From fbedafd267f6602d994bc9d98ffa85e8a486e9db Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:10:19 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=9A=94=EC=95=BD=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BacktestSummation.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/_BacktestingPage/components/BacktestSummation.tsx 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; From 203db6d5cd82b4bc7f050d638300b75c18700800 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:10:32 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EC=A7=80=EC=88=98=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BacktestResultTable.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/_BacktestingPage/components/BacktestResultTable.tsx 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; From 0375c8d2be017349b6fd44b83d5470759f54b6b9 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:16:59 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=B0=A8=ED=8A=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BacktestChart.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/_BacktestingPage/components/BacktestChart.tsx diff --git a/src/_BacktestingPage/components/BacktestChart.tsx b/src/_BacktestingPage/components/BacktestChart.tsx new file mode 100644 index 0000000..a0678a8 --- /dev/null +++ b/src/_BacktestingPage/components/BacktestChart.tsx @@ -0,0 +1,95 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import type { MonthlyData } from "@/_BacktestingPage/types/backtestFormType"; +import { formatNumber } from "@/lib/utils"; + +interface BacktestChartProps { + data: MonthlyData[]; + label: string; + color?: string; + valueFormatter?: (value: number) => string; +} + +const BacktestChart = ({ + data, + label, + color = "#3b82f6", + valueFormatter = (value) => formatNumber(value), +}: BacktestChartProps) => { + // 날짜 포맷팅 (YYYY-MM-DD -> MM/DD) + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${month}/${day}`; + }; + + // 차트 데이터 변환 + const chartData = data.map((item) => ({ + date: formatDate(item.date), + value: item.value, + })); + + // 커스텀 툴팁 + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].payload.date}

+

+ {label}: {valueFormatter(payload[0].value)} +

+
+ ); + } + return null; + }; + + return ( + + + {label} + + +
+ + + + + + } /> + + + +
+
+
+ ); +}; + +export default BacktestChart; From 3a83143608f625f31ecfe84e86e28ac0b68f3cb9 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:19:46 +0900 Subject: [PATCH 09/13] =?UTF-8?q?chore:=20tooltip=20any=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BacktestChart.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/_BacktestingPage/components/BacktestChart.tsx b/src/_BacktestingPage/components/BacktestChart.tsx index a0678a8..6c90b87 100644 --- a/src/_BacktestingPage/components/BacktestChart.tsx +++ b/src/_BacktestingPage/components/BacktestChart.tsx @@ -8,6 +8,7 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; +import type { TooltipProps } from "recharts"; import type { MonthlyData } from "@/_BacktestingPage/types/backtestFormType"; import { formatNumber } from "@/lib/utils"; @@ -18,6 +19,18 @@ interface BacktestChartProps { valueFormatter?: (value: number) => string; } +interface ChartDataPoint { + date: string; + value: number; +} + +type CustomTooltipProps = TooltipProps & { + payload?: Array<{ + value: number; + payload: ChartDataPoint; + }>; +}; + const BacktestChart = ({ data, label, @@ -39,16 +52,19 @@ const BacktestChart = ({ })); // 커스텀 툴팁 - const CustomTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - return ( -
-

{payload[0].payload.date}

-

- {label}: {valueFormatter(payload[0].value)} -

-
- ); + const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { + if (active && payload && payload.length > 0) { + const dataPoint = payload[0]; + if (dataPoint && dataPoint.payload) { + return ( +
+

{dataPoint.payload.date}

+

+ {label}: {valueFormatter(dataPoint.value)} +

+
+ ); + } } return null; }; From e8d768fe50126a42501a2e4c59afb963cabea85f Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:20:21 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BacktestResult.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/_BacktestingPage/components/BacktestResult.tsx 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; From 4af9d33e43ecc64ae10dfd7df215493d4f43a5b4 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 12:42:11 +0900 Subject: [PATCH 11/13] =?UTF-8?q?style:=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=9E=85=EB=A0=A5=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AssetAllocation.tsx | 57 +++-- src/_BacktestingPage/components/AssetItem.tsx | 19 +- .../components/BacktestForm.tsx | 232 +++++++++--------- src/_BacktestingPage/components/Notice.tsx | 21 +- .../components/StartBacktestButton.tsx | 20 +- .../components/WeightSummary.tsx | 8 +- src/components/Title.tsx | 2 +- src/components/ui/datepicker.tsx | 2 +- src/pages/BacktestingPage.tsx | 17 +- 9 files changed, 202 insertions(+), 176 deletions(-) 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/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
-
    - {notices.map((notice, idx) => ( -
  • {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/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<