From 00bb21640a97de126b4dae0c102e5fe20b99a85e Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:20:44 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20API=20=ED=95=A8=EC=88=98=20=EB=B0=8F=20=ED=9B=85=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/api.ts | 1 + src/lib/apis/postBacktest.ts | 31 +++++++++++++++++++++++++++++++ src/lib/hooks/usePostBacktest.ts | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 src/lib/apis/postBacktest.ts create mode 100644 src/lib/hooks/usePostBacktest.ts 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 }; +}; From 6f973d8630b03c4d1c8e8cc894a8ec6ac53d2899 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:21:19 +0900 Subject: [PATCH 02/11] =?UTF-8?q?type:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=B6=94=EC=96=B4=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../types/backtestFormType.ts | 16 ++++++--------- src/_BacktestingPage/utils/mapToRequest.ts | 20 ++++++++++++------- 2 files changed, 19 insertions(+), 17 deletions(-) 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/mapToRequest.ts b/src/_BacktestingPage/utils/mapToRequest.ts index a6a471f..207c36a 100644 --- a/src/_BacktestingPage/utils/mapToRequest.ts +++ b/src/_BacktestingPage/utils/mapToRequest.ts @@ -5,14 +5,20 @@ 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, })), }; From 8ccfb37a9ca23ea9302029416a553475fc8cf73a Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:51:17 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20Progress=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=84=A4=EC=B9=98=20=EB=B0=8F=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 79 +++++++++++++++++++++++ src/_BacktestingPage/hooks/useProgress.ts | 59 +++++++++++++++++ src/components/ui/progress.tsx | 26 ++++++++ 4 files changed, 165 insertions(+) create mode 100644 src/_BacktestingPage/hooks/useProgress.ts create mode 100644 src/components/ui/progress.tsx 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/hooks/useProgress.ts b/src/_BacktestingPage/hooks/useProgress.ts new file mode 100644 index 0000000..8749f23 --- /dev/null +++ b/src/_BacktestingPage/hooks/useProgress.ts @@ -0,0 +1,59 @@ +import { useState, useEffect } from "react"; + +interface UseProgressProps { + isPending: boolean; + data: unknown; + error: Error | null; +} + +export const useProgress = ({ isPending, data, error }: UseProgressProps) => { + 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); + }, 300); + + // 66%까지 진행 (응답 대기) + timer2 = setTimeout(() => { + setProgress(66); + }, 200); + } else if (data && !error) { + // 응답이 오면 100%로 진행 + timer3 = setTimeout(() => { + setProgress(100); + // 100%가 된 후 데이터 렌더링 + timer4 = setTimeout(() => { + setShowResult(true); + }, 300); + }, 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/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..08b43a1 --- /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 }; From c5c9715fc239c550a0667a66068f8bbf1ea867e4 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:58:39 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20Progress=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=EC=8B=9C=EA=B0=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_BacktestingPage/hooks/useProgress.ts | 7 +++---- src/components/ui/progress.tsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/_BacktestingPage/hooks/useProgress.ts b/src/_BacktestingPage/hooks/useProgress.ts index 8749f23..0ac5c4f 100644 --- a/src/_BacktestingPage/hooks/useProgress.ts +++ b/src/_BacktestingPage/hooks/useProgress.ts @@ -25,12 +25,12 @@ export const useProgress = ({ isPending, data, error }: UseProgressProps) => { // 33%까지 300ms 동안 진행 timer1 = setTimeout(() => { setProgress(33); - }, 300); + }, 100); // 66%까지 진행 (응답 대기) timer2 = setTimeout(() => { setProgress(66); - }, 200); + }, 500); } else if (data && !error) { // 응답이 오면 100%로 진행 timer3 = setTimeout(() => { @@ -38,7 +38,7 @@ export const useProgress = ({ isPending, data, error }: UseProgressProps) => { // 100%가 된 후 데이터 렌더링 timer4 = setTimeout(() => { setShowResult(true); - }, 300); + }, 350); }, 100); } else if (error) { // 에러 발생 시 progress 초기화 @@ -56,4 +56,3 @@ export const useProgress = ({ isPending, data, error }: UseProgressProps) => { return { progress, showResult }; }; - diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index 08b43a1..9941fcf 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -16,7 +16,7 @@ function Progress({ > From 066b4978c9e4f5a7426b199ecdc4e7ea5c0444ab Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:58:51 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=B0=B1=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/BacktestingPage.tsx | 78 ++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index 58ea82e..742196d 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -13,8 +13,12 @@ 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"; -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 }]); @@ -30,7 +34,10 @@ const BacktestingPage = () => { rebalanceFrequency: "매년", }, }); - const handleSubmit = () => { + const { mutate, isPending, error, data } = usePostBacktest(); + const { progress, showResult } = useProgress({ isPending, data, error }); + + const handleSubmit = form.handleSubmit((formData) => { const hasInvalidAsset = assets.some((asset) => { return !asset.name || !asset.ticker || asset.weight < 1 || asset.weight > 100; }); @@ -45,27 +52,9 @@ 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 (
@@ -81,9 +70,48 @@ ${requestData.assets - + + {/* 로딩 상태 또는 Progress 진행 중 */} + {(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 && ( + + )}
); }; From 9e129385ade4936b705cf0a04ad7d39bb05632c4 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 15:59:14 +0900 Subject: [PATCH 06/11] =?UTF-8?q?chore:=20prettier=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_BacktestingPage/utils/mapToRequest.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/_BacktestingPage/utils/mapToRequest.ts b/src/_BacktestingPage/utils/mapToRequest.ts index 207c36a..a80e6a8 100644 --- a/src/_BacktestingPage/utils/mapToRequest.ts +++ b/src/_BacktestingPage/utils/mapToRequest.ts @@ -6,11 +6,12 @@ export function mapToBacktestRequest(values: BacktestFormSchema, assets: Asset[] const formatDate = (date: Date) => date.toLocaleDateString("sv-SE"); // 리밸런싱 주기를 영어로 변환 - const rebalanceCycleMap: Record<"매년" | "분기별" | "매월", "YEARLY" | "QUARTERLY" | "MONTHLY"> = { - 매년: "YEARLY", - 분기별: "QUARTERLY", - 매월: "MONTHLY", - }; + const rebalanceCycleMap: Record<"매년" | "분기별" | "매월", "YEARLY" | "QUARTERLY" | "MONTHLY"> = + { + 매년: "YEARLY", + 분기별: "QUARTERLY", + 매월: "MONTHLY", + }; return { startDate: formatDate(values.startDate), From d5c80115e83d10d21dc7bd6e746c7544d45e12c8 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 16:05:01 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20Pending=20UI=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/BacktestingPage.tsx | 95 +++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index 742196d..11ed6db 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -9,7 +9,7 @@ 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"; @@ -36,8 +36,29 @@ const BacktestingPage = () => { }); 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; }); @@ -57,7 +78,7 @@ const BacktestingPage = () => { }); return ( -
+
@@ -68,49 +89,57 @@ const BacktestingPage = () => { totalWeight={totalWeight} > - +
+ +
- {/* 로딩 상태 또는 Progress 진행 중 */} + {/* 로딩 상태 또는 Progress 진행 중 - 전체 화면 overlay */} {(isPending || (progress > 0 && progress < 100)) && ( - - -
-
- -

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

+
+ + +
+
+ +

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

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

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

-

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

+
+ + +
+
+

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

+

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

+
-
- - + + +
)} {/* 성공 상태 - Progress가 100%가 되고 showResult가 true일 때만 렌더링 */} {showResult && !error && data?.isSuccess && data?.result && ( - +
+ +
)}
); From aa12a3d3eb37f17ad1520f0a32907291a88d31d6 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 16:10:47 +0900 Subject: [PATCH 08/11] =?UTF-8?q?chore:=20=EC=A2=85=EB=A3=8C=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=98=A4=EB=8A=98=20?= =?UTF-8?q?->=20=EC=96=B4=EC=A0=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/BacktestingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx index 11ed6db..b55a2d0 100644 --- a/src/pages/BacktestingPage.tsx +++ b/src/pages/BacktestingPage.tsx @@ -29,7 +29,7 @@ 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: "매년", }, From 3dd4dc1ab1be8517cc07a0b1abae9a55bca89237 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 16:14:06 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=EC=A2=85=EB=AA=A9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=A1=9C=EB=94=A9=EC=A4=91=20UI=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/_BacktestingPage/components/AssetItem.tsx | 85 +++++++++++-------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/src/_BacktestingPage/components/AssetItem.tsx b/src/_BacktestingPage/components/AssetItem.tsx index 8ee4ea0..c27118b 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; @@ -18,7 +19,7 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => const debouncedQuery = useDebounce(query, 500); - const { data: searchAssets } = useGetSearchAssets(debouncedQuery); + const { data: searchAssets, isLoading } = useGetSearchAssets(debouncedQuery); const searchResults: SearchResult[] = searchAssets ? searchAssets.map((item) => ({ @@ -27,18 +28,20 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => })) : []; - // 검색 결과가 있을 때 드롭다운 열기 + // 검색 결과가 있을 때 드롭다운 열기, 값이 없으면 닫기 useEffect(() => { if (skipSearchRef.current) { skipSearchRef.current = false; return; } - if (searchResults.length > 0 && debouncedQuery) { + if (isLoading && debouncedQuery) { setIsDropdownOpen(true); - } else if (!debouncedQuery) { + } else if (searchResults.length > 0 && debouncedQuery) { + setIsDropdownOpen(true); + } else if (!debouncedQuery || (!isLoading && searchResults.length === 0)) { setIsDropdownOpen(false); } - }, [searchResults, debouncedQuery]); + }, [searchResults, debouncedQuery, isLoading]); const handleSelect = (selected: SearchResult) => { const displayValue = `${selected.name} (${selected.ticker})`; @@ -65,39 +68,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}) +
+ )) + )} +
+ )} +
Date: Tue, 18 Nov 2025 16:18:29 +0900 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20=EC=A2=85=EB=AA=A9=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=8B=9C=20=EB=A1=9C=EB=94=A9=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=82=98=EC=98=A4=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_BacktestingPage/components/AssetItem.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/_BacktestingPage/components/AssetItem.tsx b/src/_BacktestingPage/components/AssetItem.tsx index c27118b..6ecc58b 100644 --- a/src/_BacktestingPage/components/AssetItem.tsx +++ b/src/_BacktestingPage/components/AssetItem.tsx @@ -16,6 +16,7 @@ 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); @@ -30,10 +31,24 @@ 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 (query.includes("(") && query.includes(")")) { + setIsDropdownOpen(false); + return; + } + if (isLoading && debouncedQuery) { setIsDropdownOpen(true); } else if (searchResults.length > 0 && debouncedQuery) { @@ -41,12 +56,13 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => } else if (!debouncedQuery || (!isLoading && searchResults.length === 0)) { setIsDropdownOpen(false); } - }, [searchResults, debouncedQuery, isLoading]); + }, [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; From eeb3215fb87e85fca8340c960c824a00b0c72898 Mon Sep 17 00:00:00 2001 From: jjamming Date: Tue, 18 Nov 2025 16:26:46 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20build=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_BacktestingPage/utils/convertAssetsForRequest.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/_BacktestingPage/utils/convertAssetsForRequest.ts 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, - })); -};