diff --git a/package.json b/package.json index 0fda811..59ba32d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@hookform/resolvers": "^5.1.1", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827b9f5..f19dfb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.14 version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-radio-group': specifier: ^1.3.7 version: 1.3.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -572,6 +575,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -703,6 +715,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.8': resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} peerDependencies: @@ -738,6 +776,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tabs@1.1.13': resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: @@ -2954,6 +3001,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-context@1.1.3(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-direction@1.1.1(@types/react@19.1.13)(react@19.1.1)': dependencies: react: 19.1.1 @@ -3076,6 +3129,25 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3118,6 +3190,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.4(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/src/_BacktestingPage/components/AssetItem.tsx b/src/_BacktestingPage/components/AssetItem.tsx index 8ee4ea0..6ecc58b 100644 --- a/src/_BacktestingPage/components/AssetItem.tsx +++ b/src/_BacktestingPage/components/AssetItem.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import type { SearchResult, Asset } from "@/_BacktestingPage/types/backtestFormType"; import { useGetSearchAssets } from "@/lib/hooks/useGetSearchAssets"; import { useDebounce } from "@/lib/hooks/useDebounce"; +import { Spinner } from "@/components/ui/spinner"; type AssetItemProps = { AssetIndex: number; asset: Asset; @@ -15,10 +16,11 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => const wrapperRef = useRef(null); const skipSearchRef = useRef(false); const hasClearedRef = useRef(false); + const isSelectedRef = useRef(false); const debouncedQuery = useDebounce(query, 500); - const { data: searchAssets } = useGetSearchAssets(debouncedQuery); + const { data: searchAssets, isLoading } = useGetSearchAssets(debouncedQuery); const searchResults: SearchResult[] = searchAssets ? searchAssets.map((item) => ({ @@ -27,23 +29,40 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => })) : []; - // 검색 결과가 있을 때 드롭다운 열기 + // 검색 결과가 있을 때 드롭다운 열기, 값이 없으면 닫기 useEffect(() => { + // 종목 선택 후에는 드롭다운을 열지 않음 + if (isSelectedRef.current) { + isSelectedRef.current = false; + setIsDropdownOpen(false); + return; + } + if (skipSearchRef.current) { skipSearchRef.current = false; return; } - if (searchResults.length > 0 && debouncedQuery) { + + // 선택된 종목 표시 형식 (이름 (티커))인 경우 드롭다운을 열지 않음 + if (query.includes("(") && query.includes(")")) { + setIsDropdownOpen(false); + return; + } + + if (isLoading && debouncedQuery) { + setIsDropdownOpen(true); + } else if (searchResults.length > 0 && debouncedQuery) { setIsDropdownOpen(true); - } else if (!debouncedQuery) { + } else if (!debouncedQuery || (!isLoading && searchResults.length === 0)) { setIsDropdownOpen(false); } - }, [searchResults, debouncedQuery]); + }, [searchResults, debouncedQuery, isLoading, query]); const handleSelect = (selected: SearchResult) => { const displayValue = `${selected.name} (${selected.ticker})`; onUpdate({ ...asset, name: selected.name, ticker: selected.ticker }); skipSearchRef.current = true; + isSelectedRef.current = true; setQuery(displayValue); setIsDropdownOpen(false); hasClearedRef.current = false; @@ -65,39 +84,47 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) =>
자산 {AssetIndex + 1}
- { - setQuery(e.target.value); - hasClearedRef.current = true; // 입력 중엔 다시 초기화 안되게 - }} - placeholder="종목명 입력" - onFocus={() => { - if (!hasClearedRef.current && query.includes("(")) { - setQuery(""); - setIsDropdownOpen(true); - hasClearedRef.current = true; - } else { - if (searchResults.length > 0) setIsDropdownOpen(true); - } - }} - /> +
+ { + setQuery(e.target.value); + hasClearedRef.current = true; // 입력 중엔 다시 초기화 안되게 + }} + placeholder="종목명 입력" + onFocus={() => { + if (!hasClearedRef.current && query.includes("(")) { + setQuery(""); + setIsDropdownOpen(true); + hasClearedRef.current = true; + } else { + if (searchResults.length > 0 || isLoading) setIsDropdownOpen(true); + } + }} + /> - {isDropdownOpen && searchResults.length > 0 && ( -
- {searchResults.map((item) => ( -
handleSelect(item)} - > - {item.name} - ({item.ticker}) -
- ))} -
- )} + {isDropdownOpen && (isLoading || searchResults.length > 0) && ( +
+ {isLoading && searchResults.length === 0 ? ( +
+ +
+ ) : ( + searchResults.map((item) => ( +
handleSelect(item)} + > + {item.name} + ({item.ticker}) +
+ )) + )} +
+ )} +
{ + const [progress, setProgress] = useState(0); + const [showResult, setShowResult] = useState(false); + + // Progress 진행률 관리 + useEffect(() => { + let timer1: NodeJS.Timeout | null = null; + let timer2: NodeJS.Timeout | null = null; + let timer3: NodeJS.Timeout | null = null; + let timer4: NodeJS.Timeout | null = null; + + if (isPending) { + // 초기화 + setProgress(0); + setShowResult(false); + + // 33%까지 300ms 동안 진행 + timer1 = setTimeout(() => { + setProgress(33); + }, 100); + + // 66%까지 진행 (응답 대기) + timer2 = setTimeout(() => { + setProgress(66); + }, 500); + } else if (data && !error) { + // 응답이 오면 100%로 진행 + timer3 = setTimeout(() => { + setProgress(100); + // 100%가 된 후 데이터 렌더링 + timer4 = setTimeout(() => { + setShowResult(true); + }, 350); + }, 100); + } else if (error) { + // 에러 발생 시 progress 초기화 + setProgress(0); + setShowResult(false); + } + + return () => { + if (timer1) clearTimeout(timer1); + if (timer2) clearTimeout(timer2); + if (timer3) clearTimeout(timer3); + if (timer4) clearTimeout(timer4); + }; + }, [isPending, data, error]); + + return { progress, showResult }; +}; diff --git a/src/_BacktestingPage/types/backtestFormType.ts b/src/_BacktestingPage/types/backtestFormType.ts index e98769c..4756c6f 100644 --- a/src/_BacktestingPage/types/backtestFormType.ts +++ b/src/_BacktestingPage/types/backtestFormType.ts @@ -1,10 +1,3 @@ -export type BacktestFormValues = { - start_date: string; // "2010-01-01" - end_date: string; // "2020-12-31" - rebalance_frequency: "매년" | "분기별" | "매월"; - initial_amount: number; -}; - export type Asset = { id: string; ticker: string; @@ -13,12 +6,15 @@ export type Asset = { }; export type AssetRequest = { - name: string; - ticker: string; + stockCd: string; weight: number; }; -export type BacktestRequest = BacktestFormValues & { +export type BacktestRequest = { + startDate: string; // "2017-11-18" + endDate: string; // "2025-11-18" + initialCapital: number; + rebalanceCycle: "YEARLY" | "QUARTERLY" | "MONTHLY"; assets: AssetRequest[]; }; diff --git a/src/_BacktestingPage/utils/convertAssetsForRequest.ts b/src/_BacktestingPage/utils/convertAssetsForRequest.ts deleted file mode 100644 index 24865cf..0000000 --- a/src/_BacktestingPage/utils/convertAssetsForRequest.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Asset, AssetRequest } from "@/_BacktestingPage/types/backtestFormType"; - -// Asset type에서 id를 제거 -export const convertAssetsForRequest = (assets: Asset[]): AssetRequest[] => { - return assets.map(({ name, ticker, weight }) => ({ - name, - ticker, - weight, - })); -}; diff --git a/src/_BacktestingPage/utils/mapToRequest.ts b/src/_BacktestingPage/utils/mapToRequest.ts index a6a471f..a80e6a8 100644 --- a/src/_BacktestingPage/utils/mapToRequest.ts +++ b/src/_BacktestingPage/utils/mapToRequest.ts @@ -5,14 +5,21 @@ import type { Asset, BacktestRequest } from "@/_BacktestingPage/types/backtestFo export function mapToBacktestRequest(values: BacktestFormSchema, assets: Asset[]): BacktestRequest { const formatDate = (date: Date) => date.toLocaleDateString("sv-SE"); + // 리밸런싱 주기를 영어로 변환 + const rebalanceCycleMap: Record<"매년" | "분기별" | "매월", "YEARLY" | "QUARTERLY" | "MONTHLY"> = + { + 매년: "YEARLY", + 분기별: "QUARTERLY", + 매월: "MONTHLY", + }; + return { - start_date: formatDate(values.startDate), - end_date: formatDate(values.endDate), - initial_amount: values.initialAmount, - rebalance_frequency: values.rebalanceFrequency, - assets: assets.map(({ name, ticker, weight }) => ({ - name, - ticker, + startDate: formatDate(values.startDate), + endDate: formatDate(values.endDate), + initialCapital: values.initialAmount * 10000, // 만원 단위를 원 단위로 변환 + rebalanceCycle: rebalanceCycleMap[values.rebalanceFrequency], + assets: assets.map(({ ticker, weight }) => ({ + stockCd: ticker, weight, })), }; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..9941fcf --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/utils"; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/constants/api.ts b/src/constants/api.ts index 4fa7e1a..060cbdc 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -19,4 +19,5 @@ export const API_ENDPOINTS = { }, stockList: (page: number, size: number) => `/api/stock/market-cap?page=${page}&size=${size}&sort=marketCap%2CDESC`, + backtest: () => `/api/backtest`, }; diff --git a/src/lib/apis/postBacktest.ts b/src/lib/apis/postBacktest.ts new file mode 100644 index 0000000..e293e9d --- /dev/null +++ b/src/lib/apis/postBacktest.ts @@ -0,0 +1,31 @@ +import { API_ENDPOINTS } from "@/constants/api"; +import { instance } from "@/utils/instance"; +import type { BacktestRequest, BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; +import type { ApiResponse } from "@/lib/apis/types"; + +export const postBacktest = async (data: BacktestRequest): Promise> => { + const response = await instance.post>(API_ENDPOINTS.backtest(), data); + + // response.data가 없거나 구조가 다른 경우 처리 + if (!response.data) { + throw new Error("응답 데이터가 없습니다."); + } + + // 응답이 문자열인 경우 JSON 파싱 시도 + let parsedData = response.data; + if (typeof response.data === "string") { + try { + parsedData = JSON.parse(response.data); + } catch { + throw new Error("응답 데이터 파싱에 실패했습니다."); + } + } + + // isSuccess가 false인 경우에만 에러 throw + if (parsedData.isSuccess === false) { + const errorMessage = parsedData.message || "백테스트 수행 중 오류가 발생했습니다."; + throw new Error(errorMessage); + } + + return parsedData; +}; diff --git a/src/lib/hooks/usePostBacktest.ts b/src/lib/hooks/usePostBacktest.ts new file mode 100644 index 0000000..8f8fa36 --- /dev/null +++ b/src/lib/hooks/usePostBacktest.ts @@ -0,0 +1,16 @@ +import { useMutation } from "@tanstack/react-query"; +import { postBacktest } from "@/lib/apis/postBacktest"; +import type { BacktestResult } from "@/_BacktestingPage/types/backtestFormType"; +import type { ApiResponse } from "@/lib/apis/types"; + +export const usePostBacktest = () => { + const { mutate, isPending, error, data } = useMutation< + ApiResponse, + Error, + Parameters[0] + >({ + mutationFn: postBacktest, + }); + + return { mutate, isPending, error, data }; +}; diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index 58ea82e..b55a2d0 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -9,12 +9,16 @@ import { backtestFormSchema, type BacktestFormSchema, } from "@/_BacktestingPage/utils/backtestFormSchema"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useRef, useEffect } 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"; -import { Card } from "@/components/ui/card"; +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"; const BacktestingPage = () => { const [assets, setAssets] = useState([{ id: uuidv4(), name: "", ticker: "", weight: 0 }]); @@ -25,12 +29,36 @@ const BacktestingPage = () => { resolver: zodResolver(backtestFormSchema), defaultValues: { startDate: new Date(), - endDate: new Date(), + endDate: new Date(new Date().setDate(new Date().getDate() - 1)), initialAmount: 1000, rebalanceFrequency: "매년", }, }); - const handleSubmit = () => { + 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); + + // 에러나 결과가 나오면 스크롤을 아래로 내리기 + useEffect(() => { + if (showResult && resultRef.current) { + setTimeout(() => { + resultRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 100); + } else if (error && !isPending && errorRef.current) { + setTimeout(() => { + errorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 100); + } + }, [showResult, error, 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; }); @@ -45,30 +73,12 @@ const BacktestingPage = () => { return; } - const formData = form.getValues(); const requestData = mapToBacktestRequest(formData, assets); - // TODO: 백테스트 API 호출 로직 추가 - const message = ` -📊 백테스트 요청 데이터 - -시작일: ${requestData.start_date} -종료일: ${requestData.end_date} -초기금액: ${requestData.initial_amount} 만원 -리밸런싱 주기: ${requestData.rebalance_frequency} - -📈 자산 목록: -${requestData.assets - .map( - (asset, idx) => ` ${idx + 1}. 종목명: ${asset.name} (${asset.ticker}), 비중: ${asset.weight}%` - ) - .join("\n")} -`; - - alert(message); - }; + mutate(requestData); + }); return ( -
+
@@ -79,11 +89,58 @@ ${requestData.assets totalWeight={totalWeight} > - - +
+ +
+ + {/* 로딩 상태 또는 Progress 진행 중 - 전체 화면 overlay */} + {(isPending || (progress > 0 && progress < 100)) && ( +
+ + +
+
+ +

백테스트를 수행 중입니다...

+
+
+
+
+
+ )} + + {/* 에러 상태 */} + {error && !isPending && progress === 0 && ( +
+ + +
+
+

+ 백테스트 수행 중 오류가 발생했습니다 +

+

+ {error instanceof Error + ? error.message + : (error as AxiosError).response?.data?.detail || + "알 수 없는 오류가 발생했습니다."} +

+
+
+
+
+
+ )} + + {/* 성공 상태 - Progress가 100%가 되고 showResult가 true일 때만 렌더링 */} + {showResult && !error && data?.isSuccess && data?.result && ( +
+ +
+ )}
); };