diff --git a/src/_BacktestingPage/components/SaveBacktestButton.tsx b/src/_BacktestingPage/components/SaveBacktestButton.tsx new file mode 100644 index 0000000..8f73fc3 --- /dev/null +++ b/src/_BacktestingPage/components/SaveBacktestButton.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Save } from "lucide-react"; +import { useAuth } from "@/lib/hooks/useAuth"; +import { useSaveBacktest } from "@/lib/hooks/useBacktestStorage"; +import type { BacktestRequest, BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; +import { toast } from "sonner"; + +interface SaveBacktestButtonProps { + request: BacktestRequest; + result: BacktestResult; +} + +const SaveBacktestButton = ({ request, result }: SaveBacktestButtonProps) => { + const { user } = useAuth(); + const { mutate: save, isPending } = useSaveBacktest(); + const [showTooltip, setShowTooltip] = useState(false); + const [saved, setSaved] = useState(false); + + const handleSave = () => { + if (!user) return; + + const count = Date.now().toString(36).slice(-4); + const title = `${user.id.slice(0, 8)}의 포트폴리오 ${count}`; + + save( + { title, request, result }, + { + onSuccess: () => { + toast.success("백테스트 결과가 저장되었습니다."); + setSaved(true); + }, + onError: () => { + toast.error("저장에 실패했습니다."); + }, + } + ); + }; + + return ( +
+
!user && setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + +
+ + {showTooltip && !user && ( +
+ 로그인하면 백테스트 결과를 저장할 수 있어요 +
+
+ )} +
+ ); +}; + +export default SaveBacktestButton; diff --git a/src/_MainPage/components/MarketIndexSection.tsx b/src/_MainPage/components/MarketIndexSection.tsx index 80b4d25..1b7f43f 100644 --- a/src/_MainPage/components/MarketIndexSection.tsx +++ b/src/_MainPage/components/MarketIndexSection.tsx @@ -1,63 +1,84 @@ -/* eslint-disable no-console */ -import { getIndexData } from "@/lib/apis/getIndex"; +// --- 실제 API 모드 (백엔드 연결 시 주석 해제하고 아래 목데이터 모드 제거) --- +// /* eslint-disable no-console */ +// import { getIndexData } from "@/lib/apis/getIndex"; +// import MarketIndexCard from "@/_MainPage/components/MarketIndexCard"; +// import { useQuery } from "@tanstack/react-query"; +// import type { AxiosError } from "axios"; +// import type { ApiErrorResponse } from "@/lib/apis/types"; +// import { __DEV__ } from "@/utils/instance"; +// +// export default function MarketIndexSection() { +// const { +// data: kospiData, +// isLoading: isKospiLoading, +// error: kospiError, +// } = useQuery({ +// queryKey: ["indexData", "KOSPI"], +// queryFn: () => getIndexData("KOSPI"), +// }); +// +// const { +// data: kosdaqData, +// isLoading: isKosdaqLoading, +// error: kosdaqError, +// } = useQuery({ +// queryKey: ["indexData", "KOSDAQ"], +// queryFn: () => getIndexData("KOSDAQ"), +// }); +// +// if (__DEV__ && kospiError) { +// const error = kospiError as AxiosError; +// if (error.response?.data) { +// console.error("KOSPI 데이터 조회 에러:", error.response.data.detail); +// } +// } +// +// if (__DEV__ && kosdaqError) { +// const error = kosdaqError as AxiosError; +// if (error.response?.data) { +// console.error("KOSDAQ 데이터 조회 에러:", error.response.data.detail); +// } +// } +// +// return ( +//
+//
+// +// +//
+//
+// ); +// } + +// --- 목데이터 모드 --- import MarketIndexCard from "@/_MainPage/components/MarketIndexCard"; -import { useQuery } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import type { ApiErrorResponse } from "@/lib/apis/types"; -import { __DEV__ } from "@/utils/instance"; +import { MOCK_KOSPI, MOCK_KOSDAQ } from "@/_MainPage/mocks/marketIndexMock"; export default function MarketIndexSection() { - // KOSPI 데이터 조회 - 독립적으로 실행 - const { - data: kospiData, - isLoading: isKospiLoading, - error: kospiError, - } = useQuery({ - queryKey: ["indexData", "KOSPI"], - queryFn: () => getIndexData("KOSPI"), - }); - - // KOSDAQ 데이터 조회 - 독립적으로 실행 - const { - data: kosdaqData, - isLoading: isKosdaqLoading, - error: kosdaqError, - } = useQuery({ - queryKey: ["indexData", "KOSDAQ"], - queryFn: () => getIndexData("KOSDAQ"), - }); - - // 에러 처리 (개발 환경에서만 로깅) - if (__DEV__ && kospiError) { - const error = kospiError as AxiosError; - if (error.response?.data) { - console.error("KOSPI 데이터 조회 에러:", error.response.data.detail); - } - } - - if (__DEV__ && kosdaqError) { - const error = kosdaqError as AxiosError; - if (error.response?.data) { - console.error("KOSDAQ 데이터 조회 에러:", error.response.data.detail); - } - } - return (
- {/* KOSPI 카드 - 독립적인 로딩/에러 상태 */} - {/* KOSDAQ 카드 - 독립적인 로딩/에러 상태 */}
diff --git a/src/_MainPage/mocks/marketIndexMock.ts b/src/_MainPage/mocks/marketIndexMock.ts new file mode 100644 index 0000000..27edfcb --- /dev/null +++ b/src/_MainPage/mocks/marketIndexMock.ts @@ -0,0 +1,157 @@ +import type { MarketIndex } from "@/_MainPage/types/MarketIndexType"; + +export const MOCK_KOSPI: MarketIndex = { + marketType: "KOSPI", + startDate: "2026-03-01", + endDate: "2026-03-08", + data: [ + { + marketType: "KOSPI", + baseDate: "2026-03-02", + openPrice: 5320.15, + closePrice: 5345.82, + highPrice: 5368.4, + lowPrice: 5310.2, + changeAmount: 25.67, + changeRate: 0.48, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-03", + openPrice: 5345.82, + closePrice: 5412.3, + highPrice: 5425.1, + lowPrice: 5338.5, + changeAmount: 66.48, + changeRate: 1.24, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-04", + openPrice: 5412.3, + closePrice: 5389.75, + highPrice: 5430.0, + lowPrice: 5375.2, + changeAmount: -22.55, + changeRate: -0.42, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-05", + openPrice: 5389.75, + closePrice: 5456.2, + highPrice: 5470.8, + lowPrice: 5382.1, + changeAmount: 66.45, + changeRate: 1.23, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-06", + openPrice: 5456.2, + closePrice: 5523.48, + highPrice: 5540.3, + lowPrice: 5448.9, + changeAmount: 67.28, + changeRate: 1.23, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-07", + openPrice: 5523.48, + closePrice: 5498.12, + highPrice: 5545.0, + lowPrice: 5480.5, + changeAmount: -25.36, + changeRate: -0.46, + }, + { + marketType: "KOSPI", + baseDate: "2026-03-08", + openPrice: 5498.12, + closePrice: 5567.35, + highPrice: 5580.2, + lowPrice: 5492.8, + changeAmount: 69.23, + changeRate: 1.26, + }, + ], +}; + +export const MOCK_KOSDAQ: MarketIndex = { + marketType: "KOSDAQ", + startDate: "2026-03-01", + endDate: "2026-03-08", + data: [ + { + marketType: "KOSDAQ", + baseDate: "2026-03-02", + openPrice: 1085.3, + closePrice: 1092.45, + highPrice: 1098.7, + lowPrice: 1080.1, + changeAmount: 7.15, + changeRate: 0.66, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-03", + openPrice: 1092.45, + closePrice: 1108.2, + highPrice: 1115.3, + lowPrice: 1090.8, + changeAmount: 15.75, + changeRate: 1.44, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-04", + openPrice: 1108.2, + closePrice: 1095.6, + highPrice: 1112.4, + lowPrice: 1088.3, + changeAmount: -12.6, + changeRate: -1.14, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-05", + openPrice: 1095.6, + closePrice: 1118.35, + highPrice: 1125.0, + lowPrice: 1093.2, + changeAmount: 22.75, + changeRate: 2.08, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-06", + openPrice: 1118.35, + closePrice: 1135.8, + highPrice: 1142.5, + lowPrice: 1115.4, + changeAmount: 17.45, + changeRate: 1.56, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-07", + openPrice: 1135.8, + closePrice: 1128.92, + highPrice: 1140.2, + lowPrice: 1122.6, + changeAmount: -6.88, + changeRate: -0.61, + }, + { + marketType: "KOSDAQ", + baseDate: "2026-03-08", + openPrice: 1128.92, + closePrice: 1148.5, + highPrice: 1155.3, + lowPrice: 1126.4, + changeAmount: 19.58, + changeRate: 1.73, + }, + ], +}; diff --git a/src/constants/mockBacktest.ts b/src/constants/mockBacktest.ts index 29a6fab..f14c568 100644 --- a/src/constants/mockBacktest.ts +++ b/src/constants/mockBacktest.ts @@ -1,77 +1,134 @@ -import type { BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; +import type { BacktestRequest, BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; +import type { ApiResponse } from "@/lib/apis/types"; -// 목 데이터 - 전체 백테스팅 결과 +// 목 요청 데이터 - 삼성전자 60% + SK하이닉스 40%, 5년 (2021-03 ~ 2026-02) +export const MOCK_BACKTEST_REQUEST: BacktestRequest = { + startDate: "2021-03-01", + endDate: "2026-02-28", + initialCapital: 10000000, + rebalanceCycle: "YEARLY", + assets: [ + { stockCd: "005930", weight: 60 }, + { stockCd: "000660", weight: 40 }, + ], +}; + +// 실제 주가 기반 목데이터 +// 삼성전자: 82,000(2021.03) → 178,600(2026.02), 약 2.18배 +// SK하이닉스: 135,000(2021.03) → 1,000,000(2026.02), 약 7.41배 +// 포트폴리오(60:40): 10,000,000 → 약 38,200,000 (CAGR ~30.6%) 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, + initialCapital: 10000000, + finalCapital: 14250000, + cagr: 7.35, + maxDrawdown: -25.2, + volatility: 18.4, + sharpeRatio: 0.85, + sortinoRatio: 1.08, }, kosdaqSummary: { portfolioName: "KOSDAQ", - initialCapital: 100000000, - finalCapital: 142000000, - cagr: 7.1, - maxDrawdown: -22.3, - volatility: 24.5, - sharpeRatio: 0.78, - sortinoRatio: 0.92, + initialCapital: 10000000, + finalCapital: 12180000, + cagr: 4.02, + maxDrawdown: -32.1, + volatility: 24.6, + sharpeRatio: 0.55, + sortinoRatio: 0.68, }, portfolioSummary: { - portfolioName: "나의 포트폴리오", - initialCapital: 100000000, - finalCapital: 158000000, - cagr: 9.2, - maxDrawdown: -14.8, - volatility: 16.7, - sharpeRatio: 1.58, - sortinoRatio: 1.95, + portfolioName: "삼성전자 60% + SK하이닉스 40%", + initialCapital: 10000000, + finalCapital: 38200000, + cagr: 30.63, + maxDrawdown: -33.2, + volatility: 32.8, + sharpeRatio: 1.85, + sortinoRatio: 2.42, }, - 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 }, + // 2021 - 반도체 호황 후반 + { date: "2021-03-31", value: 10000000 }, + { date: "2021-06-30", value: 9620000 }, + { date: "2021-09-30", value: 8480000 }, + { date: "2021-12-31", value: 9380000 }, + // 2022 - 반도체 다운사이클 + { date: "2022-03-31", value: 8350000 }, + { date: "2022-06-30", value: 7280000 }, + { date: "2022-09-30", value: 6680000 }, + { date: "2022-12-31", value: 6760000 }, + // 2023 - 회복 시작 + { date: "2023-03-31", value: 7520000 }, + { date: "2023-06-30", value: 9180000 }, + { date: "2023-09-30", value: 8640000 }, + { date: "2023-12-31", value: 10280000 }, + // 2024 - 하이닉스 급등, 삼성 하락 + { date: "2024-03-31", value: 10900000 }, + { date: "2024-06-30", value: 13120000 }, + { date: "2024-09-30", value: 10580000 }, + { date: "2024-12-31", value: 10020000 }, + // 2025 - AI 반도체 슈퍼사이클 + { date: "2025-03-31", value: 11400000 }, + { date: "2025-06-30", value: 18200000 }, + { date: "2025-09-30", value: 28500000 }, + { date: "2025-12-31", value: 35800000 }, + // 2026 + { date: "2026-02-28", value: 38200000 }, ], 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 }, + { date: "2021-03-31", value: 0.0 }, + { date: "2021-06-30", value: -3.8 }, + { date: "2021-09-30", value: -11.85 }, + { date: "2021-12-31", value: 10.61 }, + { date: "2022-03-31", value: -10.98 }, + { date: "2022-06-30", value: -12.81 }, + { date: "2022-09-30", value: -8.24 }, + { date: "2022-12-31", value: 1.2 }, + { date: "2023-03-31", value: 11.24 }, + { date: "2023-06-30", value: 22.07 }, + { date: "2023-09-30", value: -5.88 }, + { date: "2023-12-31", value: 18.98 }, + { date: "2024-03-31", value: 6.03 }, + { date: "2024-06-30", value: 20.37 }, + { date: "2024-09-30", value: -19.36 }, + { date: "2024-12-31", value: -5.29 }, + { date: "2025-03-31", value: 13.77 }, + { date: "2025-06-30", value: 59.65 }, + { date: "2025-09-30", value: 56.59 }, + { date: "2025-12-31", value: 25.61 }, + { date: "2026-02-28", value: 6.7 }, ], + monthlyDrawdowns: [ + { date: "2021-03-31", value: 0.0 }, + { date: "2021-06-30", value: -3.8 }, + { date: "2021-09-30", value: -15.2 }, + { date: "2021-12-31", value: -6.2 }, + { date: "2022-03-31", value: -16.5 }, + { date: "2022-06-30", value: -27.2 }, + { date: "2022-09-30", value: -33.2 }, + { date: "2022-12-31", value: -32.4 }, + { date: "2023-03-31", value: -24.8 }, + { date: "2023-06-30", value: -8.2 }, + { date: "2023-09-30", value: -13.6 }, + { date: "2023-12-31", value: -2.8 }, + { date: "2024-03-31", value: 0.0 }, + { date: "2024-06-30", value: 0.0 }, + { date: "2024-09-30", value: -19.36 }, + { date: "2024-12-31", value: -23.63 }, + { date: "2025-03-31", value: -13.12 }, + { date: "2025-06-30", value: 0.0 }, + { date: "2025-09-30", value: 0.0 }, + { date: "2025-12-31", value: 0.0 }, + { date: "2026-02-28", value: 0.0 }, + ], +}; + +// API 응답 형식 목데이터 +export const MOCK_BACKTEST_API_RESPONSE: ApiResponse = { + isSuccess: true, + code: "200", + message: "백테스트가 성공적으로 완료되었습니다.", + result: MOCK_BACKTEST_RESULT, }; diff --git a/src/lib/apis/backtestStorage.ts b/src/lib/apis/backtestStorage.ts new file mode 100644 index 0000000..fd62c76 --- /dev/null +++ b/src/lib/apis/backtestStorage.ts @@ -0,0 +1,63 @@ +import { supabase } from "@/lib/supabase"; +import type { BacktestRequest, BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; + +export interface SavedBacktest { + id: string; + user_id: string; + title: string; + request: BacktestRequest; + result: BacktestResult; + created_at: string; +} + +export async function saveBacktestResult({ + title, + request, + result, +}: { + title: string; + request: BacktestRequest; + result: BacktestResult; +}) { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error("로그인이 필요합니다."); + + const { data, error } = await supabase + .from("backtest_results") + .insert({ + user_id: user.id, + title, + request, + result, + }) + .select() + .single(); + + if (error) throw error; + return data as SavedBacktest; +} + +export async function getBacktestList() { + const { data, error } = await supabase + .from("backtest_results") + .select("id, title, request, result, created_at") + .order("created_at", { ascending: false }); + + if (error) throw error; + return data as SavedBacktest[]; +} + +export async function getBacktestById(id: string) { + const { data, error } = await supabase.from("backtest_results").select("*").eq("id", id).single(); + + if (error) throw error; + return data as SavedBacktest; +} + +export async function deleteBacktest(id: string) { + const { error } = await supabase.from("backtest_results").delete().eq("id", id); + + if (error) throw error; +} diff --git a/src/lib/hooks/useBacktestStorage.ts b/src/lib/hooks/useBacktestStorage.ts new file mode 100644 index 0000000..1db7a65 --- /dev/null +++ b/src/lib/hooks/useBacktestStorage.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + saveBacktestResult, + getBacktestList, + getBacktestById, + deleteBacktest, +} from "@/lib/apis/backtestStorage"; + +export const useBacktestList = () => { + return useQuery({ + queryKey: ["backtest-list"], + queryFn: getBacktestList, + }); +}; + +export const useBacktestDetail = (id: string) => { + return useQuery({ + queryKey: ["backtest-detail", id], + queryFn: () => getBacktestById(id), + enabled: !!id, + }); +}; + +export const useSaveBacktest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: saveBacktestResult, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["backtest-list"] }); + }, + }); +}; + +export const useDeleteBacktest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteBacktest, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["backtest-list"] }); + }, + }); +}; diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index 18129e2..eadb190 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -4,21 +4,29 @@ import Title from "@/components/Title"; import Notice from "@/_BacktestingPage/components/Notice"; import BacktestForm from "@/_BacktestingPage/components/BacktestForm"; import AssetAllocation from "@/_BacktestingPage/components/AssetAllocation"; -import StartBacktestButton from "@/_BacktestingPage/components/StartBacktestButton"; +// import StartBacktestButton from "@/_BacktestingPage/components/StartBacktestButton"; import { backtestFormSchema, type BacktestFormSchema, } from "@/_BacktestingPage/utils/backtestFormSchema"; import { useState, useMemo, useRef, useEffect } from "react"; -import { mapToBacktestRequest } from "@/_BacktestingPage/utils/mapToRequest"; +// import { mapToBacktestRequest } from "@/_BacktestingPage/utils/mapToRequest"; import { v4 as uuidv4 } from "uuid"; import BacktestResult from "@/_BacktestingPage/components/BacktestResult"; -import { Card, CardContent } from "@/components/ui/card"; -import { usePostBacktest } from "@/lib/hooks/usePostBacktest"; -import { Progress } from "@/components/ui/progress"; -import { useProgress } from "@/_BacktestingPage/hooks/useProgress"; -import type { AxiosError } from "axios"; -import type { ApiErrorResponse } from "@/lib/apis/types"; +import SaveBacktestButton from "@/_BacktestingPage/components/SaveBacktestButton"; +import type { + BacktestRequest, + BacktestResult as BacktestResultType, +} from "@/_BacktestingPage/types/backtestFormType"; +import { Card } from "@/components/ui/card"; +// import { CardContent } from "@/components/ui/card"; +// import { usePostBacktest } from "@/lib/hooks/usePostBacktest"; +// import { Progress } from "@/components/ui/progress"; +// import { useProgress } from "@/_BacktestingPage/hooks/useProgress"; +// import type { AxiosError } from "axios"; +// import type { ApiErrorResponse } from "@/lib/apis/types"; +import { MOCK_BACKTEST_REQUEST, MOCK_BACKTEST_RESULT } from "@/constants/mockBacktest"; +import { Button } from "@/components/ui/button"; const BacktestingPage = () => { const [assets, setAssets] = useState([{ id: uuidv4(), name: "", ticker: "", weight: 0 }]); @@ -34,59 +42,72 @@ const BacktestingPage = () => { rebalanceFrequency: "매년", }, }); - const { mutate, isPending, error, data } = usePostBacktest(); - const { progress, showResult } = useProgress({ isPending, data, error }); - const buttonRef = useRef(null); - const resultRef = useRef(null); - const errorRef = useRef(null); - // isSuccess가 false인 경우도 에러로 처리 - const hasError = error || (data && data.isSuccess === false); - const errorMessage = error - ? error instanceof Error - ? error.message - : (error as AxiosError).response?.data?.detail || - "알 수 없는 오류가 발생했습니다." - : data && data.isSuccess === false - ? data.message || "백테스트 수행 중 오류가 발생했습니다." - : ""; + // --- 목데이터 모드 --- + const [lastRequest, setLastRequest] = useState(null); + const [mockResult, setMockResult] = useState(null); + const resultRef = useRef(null); - // 에러나 결과가 나오면 스크롤을 아래로 내리기 useEffect(() => { - if (showResult && resultRef.current) { + if (mockResult && resultRef.current) { setTimeout(() => { resultRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 100); - } else if (hasError && !isPending && errorRef.current) { - setTimeout(() => { - errorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - }, 100); - } - }, [showResult, hasError, isPending]); - - const handleSubmit = form.handleSubmit((formData) => { - // 버튼이 화면 상단에 오도록 스크롤 - if (buttonRef.current) { - buttonRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); } + }, [mockResult]); - const hasInvalidAsset = assets.some((asset) => { - return !asset.name || !asset.ticker || asset.weight < 1 || asset.weight > 100; - }); - - if (hasInvalidAsset) { - alert("모든 자산의 종목명, 티커, 비중을 올바르게 입력해주세요."); - return; - } - - if (totalWeight !== 100) { - alert("비중의 합이 100%가 되어야 합니다."); - return; - } + const handleMockBacktest = () => { + setLastRequest(MOCK_BACKTEST_REQUEST); + setMockResult(MOCK_BACKTEST_RESULT); + }; - const requestData = mapToBacktestRequest(formData, assets); - mutate(requestData); - }); + // --- 실제 API 모드 (백엔드 연결 시 주석 해제) --- + // const { mutate, isPending, error, data } = usePostBacktest(); + // const { progress, showResult } = useProgress({ isPending, data, error }); + // const buttonRef = useRef(null); + // const errorRef = useRef(null); + // + // const hasError = error || (data && data.isSuccess === false); + // const errorMessage = error + // ? error instanceof Error + // ? error.message + // : (error as AxiosError).response?.data?.detail || + // "알 수 없는 오류가 발생했습니다." + // : data && data.isSuccess === false + // ? data.message || "백테스트 수행 중 오류가 발생했습니다." + // : ""; + // + // useEffect(() => { + // if (showResult && resultRef.current) { + // setTimeout(() => { + // resultRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + // }, 100); + // } else if (hasError && !isPending && errorRef.current) { + // setTimeout(() => { + // errorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + // }, 100); + // } + // }, [showResult, hasError, isPending]); + // + // const handleSubmit = form.handleSubmit((formData) => { + // if (buttonRef.current) { + // buttonRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); + // } + // const hasInvalidAsset = assets.some((asset) => { + // return !asset.name || !asset.ticker || asset.weight < 1 || asset.weight > 100; + // }); + // if (hasInvalidAsset) { + // alert("모든 자산의 종목명, 티커, 비중을 올바르게 입력해주세요."); + // return; + // } + // if (totalWeight !== 100) { + // alert("비중의 합이 100%가 되어야 합니다."); + // return; + // } + // const requestData = mapToBacktestRequest(formData, assets); + // setLastRequest(requestData); + // mutate(requestData); + // }); return (
@@ -100,15 +121,28 @@ const BacktestingPage = () => { totalWeight={totalWeight} > -
+ + {/* 목데이터 버튼 (백엔드 연결 시 아래 주석 해제하고 이 버튼 제거) */} +
+ +
+ + {/* 실제 API 버튼 (백엔드 연결 시 주석 해제) */} + {/*
-
+
*/} - {/* 로딩 상태 또는 Progress 진행 중 - 전체 화면 overlay */} - {(isPending || (progress > 0 && progress < 100)) && ( + {/* 실제 API 로딩 상태 (백엔드 연결 시 주석 해제) */} + {/* {(isPending || (progress > 0 && progress < 100)) && (
@@ -121,10 +155,10 @@ const BacktestingPage = () => {
- )} + )} */} - {/* 에러 상태 - error가 있거나 data.isSuccess가 false인 경우 */} - {hasError && !isPending && progress === 0 && ( + {/* 실제 API 에러 상태 (백엔드 연결 시 주석 해제) */} + {/* {hasError && !isPending && progress === 0 && (
@@ -139,14 +173,23 @@ const BacktestingPage = () => {
+ )} */} + + {/* 목데이터 결과 */} + {mockResult && lastRequest && ( +
+ + +
)} - {/* 성공 상태 - Progress가 100%가 되고 showResult가 true일 때만 렌더링 */} - {showResult && !error && data?.isSuccess && data?.result && ( + {/* 실제 API 결과 (백엔드 연결 시 주석 해제하고 위 목데이터 결과 제거) */} + {/* {showResult && !error && data?.isSuccess && data?.result && (
+ {lastRequest && }
- )} + )} */}
); }; diff --git a/src/pages/PortfolioDetailPage.tsx b/src/pages/PortfolioDetailPage.tsx new file mode 100644 index 0000000..eea46ac --- /dev/null +++ b/src/pages/PortfolioDetailPage.tsx @@ -0,0 +1,79 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useBacktestDetail, useDeleteBacktest } from "@/lib/hooks/useBacktestStorage"; +import BacktestResult from "@/_BacktestingPage/components/BacktestResult"; +import Title from "@/components/Title"; +import { Spinner } from "@/components/ui/spinner"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +const PortfolioDetailPage = () => { + const { backtestId } = useParams<{ backtestId: string }>(); + const navigate = useNavigate(); + const { data, isLoading, error } = useBacktestDetail(backtestId ?? ""); + const { mutate: deleteBacktest, isPending: isDeleting } = useDeleteBacktest(); + + const handleDelete = () => { + if (!backtestId || !confirm("정말 삭제하시겠습니까?")) return; + + deleteBacktest(backtestId, { + onSuccess: () => { + toast.success("삭제되었습니다."); + navigate("/portfolio"); + }, + onError: () => toast.error("삭제에 실패했습니다."), + }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+
데이터를 불러오는 중 오류가 발생했습니다.
+
+ ); + } + + return ( +
+
+ + <div className="flex gap-2"> + <Button + variant="ghost" + onClick={() => navigate("/portfolio")} + className="text-gray-400 hover:text-white cursor-pointer" + > + <ArrowLeft className="mr-1 w-4 h-4" /> + 목록 + </Button> + <Button + variant="ghost" + onClick={handleDelete} + disabled={isDeleting} + className="text-gray-400 hover:text-red-400 cursor-pointer" + > + <Trash2 className="mr-1 w-4 h-4" /> + 삭제 + </Button> + </div> + </div> + + <div className="mb-4 text-gray-400 text-sm"> + {data.request.startDate} ~ {data.request.endDate} | 초기 투자금:{" "} + {data.request.initialCapital.toLocaleString()}원 | 리밸런싱: {data.request.rebalanceCycle} + </div> + + <BacktestResult data={data.result} /> + </div> + ); +}; + +export default PortfolioDetailPage; diff --git a/src/pages/PortfolioPage.tsx b/src/pages/PortfolioPage.tsx index c678e26..6c33761 100644 --- a/src/pages/PortfolioPage.tsx +++ b/src/pages/PortfolioPage.tsx @@ -7,38 +7,172 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; -import { ArrowRight, ChartNoAxesCombined } from "lucide-react"; -import { Link } from "react-router-dom"; +import { ArrowRight, ChartNoAxesCombined, Trash2 } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import Title from "@/components/Title"; +import { useAuth } from "@/lib/hooks/useAuth"; +import { useBacktestList, useDeleteBacktest } from "@/lib/hooks/useBacktestStorage"; +import { Spinner } from "@/components/ui/spinner"; +import { formatNumber } from "@/lib/utils"; +import { toast } from "sonner"; const PortfolioPage = () => { + const { user, isLoading: isAuthLoading } = useAuth(); + const { data: backtests, isLoading, error } = useBacktestList(); + const { mutate: deleteBacktest } = useDeleteBacktest(); + const navigate = useNavigate(); + + const handleDelete = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (!confirm("정말 삭제하시겠습니까?")) return; + + deleteBacktest(id, { + onSuccess: () => toast.success("삭제되었습니다."), + onError: () => toast.error("삭제에 실패했습니다."), + }); + }; + + if (isAuthLoading || isLoading) { + return ( + <div className="flex justify-center items-center min-h-[calc(100vh-200px)]"> + <Spinner className="size-12" /> + </div> + ); + } + + if (!user) { + return ( + <div className="flex justify-center items-center min-h-[calc(100vh-200px)]"> + <Empty> + <EmptyHeader> + <EmptyMedia variant="icon"> + <ChartNoAxesCombined /> + </EmptyMedia> + <EmptyTitle className="mb-2 text-3xl">로그인이 필요합니다</EmptyTitle> + <EmptyDescription className="text-gray-300"> + <p>로그인하면 저장한 백테스트 결과를 확인할 수 있어요.</p> + </EmptyDescription> + </EmptyHeader> + <EmptyContent> + <Button + variant="outline" + size="lg" + className="hover:opacity-80 mt-1 px-9 py-6 rounded-3xl text-[1rem] text-navy hover:text-navy-hover" + asChild + > + <Link to="/login">로그인하기</Link> + </Button> + </EmptyContent> + </Empty> + </div> + ); + } + + if (error) { + return ( + <div className="flex justify-center items-center min-h-[calc(100vh-200px)]"> + <div className="text-red-500 text-xl">데이터를 불러오는 중 오류가 발생했습니다.</div> + </div> + ); + } + + if (!backtests || backtests.length === 0) { + return ( + <div className="flex justify-center items-center min-h-[calc(100vh-200px)]"> + <Empty> + <EmptyHeader> + <EmptyMedia variant="icon"> + <ChartNoAxesCombined /> + </EmptyMedia> + <EmptyTitle className="mb-2 text-3xl">포트폴리오가 없습니다 :(</EmptyTitle> + <EmptyDescription className="text-gray-300"> + <p>아직 저장한 백테스팅이 없어요.</p> + <p>아래 버튼을 통해 백테스팅을 수행해보세요.</p> + </EmptyDescription> + </EmptyHeader> + <EmptyContent> + <Button + variant="outline" + size="lg" + className="hover:opacity-80 mt-1 px-9 py-6 rounded-3xl text-[1rem] text-navy hover:text-navy-hover" + asChild + > + <Link to="/backtest"> + 백테스팅 시작하기 + <ArrowRight className="ml-1 w-5 h-5" /> + </Link> + </Button> + </EmptyContent> + </Empty> + </div> + ); + } + return ( - // TODO: 데이터가 있다면 바로가기 리스트를 보여준다. - <div className="flex justify-center items-center min-h-[calc(100vh-200px)]"> - <Empty> - <EmptyHeader> - <EmptyMedia variant="icon"> - <ChartNoAxesCombined /> - </EmptyMedia> - <EmptyTitle className="mb-2 text-3xl">포트폴리오가 없습니다 :(</EmptyTitle> - <EmptyDescription className="text-gray-300"> - <p>아직 수행한 백테스팅이 없어요.</p> - <p>아래 버튼을 통해 백테스팅을 수행해보세요.</p> - </EmptyDescription> - </EmptyHeader> - <EmptyContent> - <Button - variant="outline" - size="lg" - className="hover:opacity-80 mt-1 px-9 py-6 rounded-3xl text-[1rem] text-navy hover:text-navy-hover" - asChild - > - <Link to="/backtest"> - 백테스팅 시작하기 - <ArrowRight className="ml-1 w-5 h-5" /> - </Link> - </Button> - </EmptyContent> - </Empty> + <div className="px-6 md:px-12 min-h-screen font-sans text-white"> + <Title title="내 포트폴리오" /> + + <main> + <table className="w-full text-white border-collapse table-fixed"> + <thead> + <tr className="border-white/10 border-b"> + <th className="p-4 w-12 font-normal text-gray-400 text-sm text-left">#</th> + <th className="p-4 font-normal text-gray-400 text-sm text-left">포트폴리오명</th> + <th className="p-4 w-32 font-normal text-gray-400 text-sm text-left">투자금</th> + <th className="p-4 w-32 font-normal text-gray-400 text-sm text-left">최종자산</th> + <th className="p-4 w-28 font-normal text-gray-400 text-sm text-left">CAGR</th> + <th className="p-4 w-32 font-normal text-gray-400 text-sm text-left">생성일</th> + <th className="p-4 w-16 font-normal text-gray-400 text-sm text-center">삭제</th> + </tr> + </thead> + <tbody> + {backtests.map((item, index) => { + const summary = item.result.portfolioSummary; + const cagrClass = + summary.cagr > 0 + ? "text-red-500" + : summary.cagr < 0 + ? "text-blue-500" + : "text-white"; + + return ( + <tr + key={item.id} + className="hover:bg-white/5 border-white/10 border-b cursor-pointer" + onClick={() => navigate(`/portfolio/${item.id}`)} + > + <td className="p-4 text-gray-400 align-middle">{index + 1}</td> + <td className="p-4 align-middle"> + <div className="font-bold">{item.title}</div> + <div className="text-gray-400 text-sm"> + {item.request.startDate} ~ {item.request.endDate} + </div> + </td> + <td className="p-4 align-middle">{formatNumber(summary.initialCapital)}원</td> + <td className="p-4 align-middle font-bold"> + {formatNumber(summary.finalCapital)}원 + </td> + <td className={`p-4 align-middle font-bold ${cagrClass}`}> + {summary.cagr > 0 ? "+" : ""} + {summary.cagr.toFixed(2)}% + </td> + <td className="p-4 text-gray-400 align-middle"> + {new Date(item.created_at).toLocaleDateString("ko-KR")} + </td> + <td className="p-4 text-center align-middle"> + <button + onClick={(e) => handleDelete(e, item.id)} + className="hover:text-red-400 text-gray-500 transition-colors cursor-pointer" + > + <Trash2 className="w-4 h-4" /> + </button> + </td> + </tr> + ); + })} + </tbody> + </table> + </main> </div> ); }; diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index d33a0a5..ca219c6 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout"; import { MainPage, MarketsPage, MarketDetailPage, PortfolioPage, BacktestingPage } from "@/pages"; import LoginPage from "@/pages/LoginPage"; import SignupPage from "@/pages/SignupPage"; +import PortfolioDetailPage from "@/pages/PortfolioDetailPage"; export const routes: RouteObject[] = [ { @@ -21,6 +22,7 @@ export const routes: RouteObject[] = [ { path: "markets", element: <MarketsPage /> }, { path: "markets/:code", element: <MarketDetailPage /> }, { path: "portfolio", element: <PortfolioPage /> }, + { path: "portfolio/:backtestId", element: <PortfolioDetailPage /> }, { path: "backtest", element: <BacktestingPage /> }, ], }, diff --git a/supabase/backtest_results.sql b/supabase/backtest_results.sql new file mode 100644 index 0000000..86f3f98 --- /dev/null +++ b/supabase/backtest_results.sql @@ -0,0 +1,23 @@ +create table backtest_results ( + id uuid default gen_random_uuid() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + title text not null, + request jsonb not null, + result jsonb not null, + created_at timestamptz default now() not null +); + +-- RLS: 본인 데이터만 접근 +alter table backtest_results enable row level security; + +create policy "Users can insert own data" + on backtest_results for insert + with check (auth.uid() = user_id); + +create policy "Users can select own data" + on backtest_results for select + using (auth.uid() = user_id); + +create policy "Users can delete own data" + on backtest_results for delete + using (auth.uid() = user_id);