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 };
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/components/ui/spinner.tsx b/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..055ac76
--- /dev/null
+++ b/src/components/ui/spinner.tsx
@@ -0,0 +1,16 @@
+import { Loader2Icon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ );
+}
+
+export { Spinner };
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..1023a04
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsList({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/constants/api.ts b/src/constants/api.ts
index ebd058c..060cbdc 100644
--- a/src/constants/api.ts
+++ b/src/constants/api.ts
@@ -1,5 +1,23 @@
export const BACKEND_BASE_URL = import.meta.env.VITE_BACKEND_BASE_URL;
export const API_ENDPOINTS = {
- searchAsset: (keyword: string) => `api/stocks/search?keyword=${encodeURIComponent(keyword)}`,
+ indexPeriod: (marketType: "KOSPI" | "KOSDAQ", startDate?: string, endDate?: string) => {
+ const params = new URLSearchParams();
+ if (startDate !== undefined) params.append("startDate", startDate);
+ if (endDate !== undefined) params.append("endDate", endDate);
+ const query = params.toString();
+ return `/api/index/period/${marketType}${query ? `?${query}` : ""}`;
+ },
+ indexCurrent: () => `/api/index/current`,
+ searchAssets: (keyword: string) => `/api/stock?query=${encodeURIComponent(keyword)}`,
+ stockData: (stockcode: string, startDate?: string, endDate?: string) => {
+ const params = new URLSearchParams();
+ if (startDate !== undefined) params.append("startDate", startDate);
+ if (endDate !== undefined) params.append("endDate", endDate);
+ const query = params.toString();
+ return `/api/stock/${stockcode}${query ? `?${query}` : ""}`;
+ },
+ stockList: (page: number, size: number) =>
+ `/api/stock/market-cap?page=${page}&size=${size}&sort=marketCap%2CDESC`,
+ backtest: () => `/api/backtest`,
};
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 },
+ ],
+};
diff --git a/src/layouts/Layout.tsx b/src/layouts/Layout.tsx
index f17fe7f..2f1294b 100644
--- a/src/layouts/Layout.tsx
+++ b/src/layouts/Layout.tsx
@@ -1,5 +1,5 @@
import { Outlet } from "react-router-dom";
-import Navbar from "../components/Navbar/Navbar.tsx";
+import Navbar from "@/components/Navbar/Navbar";
const Layout = () => {
return (
diff --git a/src/lib/apis/getIndex.ts b/src/lib/apis/getIndex.ts
new file mode 100644
index 0000000..01c556b
--- /dev/null
+++ b/src/lib/apis/getIndex.ts
@@ -0,0 +1,28 @@
+import { format, subDays } from "date-fns";
+import { API_ENDPOINTS } from "@/constants/api";
+import { instance } from "@/utils/instance";
+import type { ApiResponse } from "@/lib/apis/types";
+import type { MarketIndex } from "@/_MainPage/types/MarketIndexType";
+
+/**
+ * 지수 데이터 조회 API
+ * @param marketType - 시장 타입 ("KOSPI" | "KOSDAQ")
+ * @returns Promise - 지수 데이터
+ */
+export const getIndexData = async (marketType: "KOSPI" | "KOSDAQ"): Promise => {
+ const today = new Date();
+ const endDate = format(today, "yyyy-MM-dd");
+ const startDate = format(subDays(today, 14), "yyyy-MM-dd");
+
+ const response = await instance.get>(
+ API_ENDPOINTS.indexPeriod(marketType, startDate, endDate)
+ );
+
+ if (response.data.isSuccess === false) {
+ const errorMessage = response.data.message || "지수 데이터 조회 중 오류가 발생했습니다.";
+ throw new Error(errorMessage);
+ }
+
+ // ApiResponse의 result 필드 반환
+ return response.data.result;
+};
diff --git a/src/lib/apis/getStockDetail.ts b/src/lib/apis/getStockDetail.ts
new file mode 100644
index 0000000..fcf12c7
--- /dev/null
+++ b/src/lib/apis/getStockDetail.ts
@@ -0,0 +1,34 @@
+import type { Period, StockData } from "@/_MarketDetailPage/types/stockDataType";
+import { API_ENDPOINTS } from "@/constants/api";
+import { instance } from "@/utils/instance";
+import { format, subDays, subMonths, subYears } from "date-fns";
+
+export const getStockDetail = async (stockCode: string, period: Period) => {
+ const endDate = format(new Date(), "yyyy-MM-dd");
+ let startDate: string;
+ switch (period) {
+ case "1W":
+ startDate = format(subDays(new Date(), 7), "yyyy-MM-dd");
+ break;
+ case "1M":
+ startDate = format(subMonths(new Date(), 1), "yyyy-MM-dd");
+ break;
+ case "1Y":
+ startDate = format(subYears(new Date(), 1), "yyyy-MM-dd");
+ break;
+ case "10Y":
+ startDate = format(subYears(new Date(), 10), "yyyy-MM-dd");
+ break;
+ default:
+ startDate = format(subMonths(new Date(), 1), "yyyy-MM-dd");
+ }
+
+ const response = await instance.get(API_ENDPOINTS.stockData(stockCode, startDate, endDate));
+
+ if (response.data.isSuccess === false) {
+ const errorMessage = response.data.message || "주식 상세 데이터 조회 중 오류가 발생했습니다.";
+ throw new Error(errorMessage);
+ }
+
+ return response.data.result as StockData;
+};
diff --git a/src/lib/apis/getStockList.ts b/src/lib/apis/getStockList.ts
new file mode 100644
index 0000000..735f9d7
--- /dev/null
+++ b/src/lib/apis/getStockList.ts
@@ -0,0 +1,17 @@
+import { API_ENDPOINTS } from "@/constants/api";
+import { instance } from "@/utils/instance";
+import type { ApiResponse } from "@/lib/apis/types";
+import type { StockListResponse } from "@/_MarketsPage/types/marketItem";
+
+export const getStockList = async (page: number, size: number) => {
+ const response = await instance.get>(
+ API_ENDPOINTS.stockList(page, size)
+ );
+
+ if (response.data.isSuccess === false) {
+ const errorMessage = response.data.message || "주식 목록 조회 중 오류가 발생했습니다.";
+ throw new Error(errorMessage);
+ }
+
+ return response.data.result;
+};
diff --git a/src/lib/apis/postBacktest.ts b/src/lib/apis/postBacktest.ts
new file mode 100644
index 0000000..a2ca469
--- /dev/null
+++ b/src/lib/apis/postBacktest.ts
@@ -0,0 +1,71 @@
+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";
+import type { AxiosError } from "axios";
+
+export const postBacktest = async (data: BacktestRequest): Promise> => {
+ try {
+ 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;
+ } catch (error) {
+ // AxiosError인 경우 response.data에서 message 추출 시도
+ const axiosError = error as AxiosError>;
+ if (axiosError.response?.data) {
+ let errorResponse = axiosError.response.data;
+
+ // 응답이 문자열인 경우 JSON 파싱 시도
+ if (typeof errorResponse === "string") {
+ try {
+ errorResponse = JSON.parse(errorResponse);
+ } catch {
+ // 파싱 실패 시 그대로 진행
+ }
+ }
+
+ // ApiResponse 형식이고 isSuccess가 false인 경우
+ if (
+ errorResponse &&
+ typeof errorResponse === "object" &&
+ "isSuccess" in errorResponse &&
+ errorResponse.isSuccess === false
+ ) {
+ const errorMessage = errorResponse.message || "백테스트 수행 중 오류가 발생했습니다.";
+ throw new Error(errorMessage);
+ }
+ }
+
+ // 이미 Error 객체인 경우 (우리가 throw한 Error 또는 다른 Error)
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ // 그 외의 경우 기본 에러 메시지
+ throw new Error("백테스트 수행 중 오류가 발생했습니다.");
+ }
+};
diff --git a/src/lib/apis/searchAssets.ts b/src/lib/apis/searchAssets.ts
new file mode 100644
index 0000000..fef239a
--- /dev/null
+++ b/src/lib/apis/searchAssets.ts
@@ -0,0 +1,20 @@
+import { instance } from "@/utils/instance";
+import { API_ENDPOINTS } from "@/constants/api";
+import type { ApiResponse, SearchAssetsResponse } from "@/lib/apis/types";
+
+export const searchAssets = async (keyword: string) => {
+ // 빈 문자열이나 공백만 있는 경우 빈 배열 반환
+ if (!keyword || !keyword.trim()) {
+ return [];
+ }
+
+ const response = await instance.get>(
+ API_ENDPOINTS.searchAssets(keyword.trim())
+ );
+
+ if (response.data.isSuccess === false) {
+ const errorMessage = response.data.message || "종목 검색 중 오류가 발생했습니다.";
+ throw new Error(errorMessage);
+ }
+ return response.data.result;
+};
diff --git a/src/lib/apis/types.ts b/src/lib/apis/types.ts
new file mode 100644
index 0000000..4e23b88
--- /dev/null
+++ b/src/lib/apis/types.ts
@@ -0,0 +1,27 @@
+/**
+ * API 응답 기본 타입
+ */
+export interface ApiResponse {
+ isSuccess: boolean;
+ code: string;
+ message: string;
+ result: T;
+}
+
+/**
+ * API 에러 응답 타입 (RFC 7807 Problem Details)
+ */
+export interface ApiErrorResponse {
+ type: string;
+ title: string;
+ status: number;
+ detail: string;
+ instance: string;
+}
+
+// 종목 검색 응답 타입
+export type SearchAssetsResponse = {
+ stockName: string;
+ stockCode: string;
+ isinCode: string;
+};
diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts
new file mode 100644
index 0000000..b22183a
--- /dev/null
+++ b/src/lib/hooks/useDebounce.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from "react";
+
+/**
+ * 값을 debounce하는 커스텀 훅
+ * @param value - debounce할 값
+ * @param delay - debounce 지연 시간 (ms)
+ * @returns debounce된 값
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/lib/hooks/useGetSearchAssets.ts b/src/lib/hooks/useGetSearchAssets.ts
new file mode 100644
index 0000000..ebaa388
--- /dev/null
+++ b/src/lib/hooks/useGetSearchAssets.ts
@@ -0,0 +1,12 @@
+import { searchAssets } from "@/lib/apis/searchAssets";
+import { useQuery } from "@tanstack/react-query";
+
+export const useGetSearchAssets = (keyword: string) => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["searchAssets", keyword],
+ queryFn: () => searchAssets(keyword),
+ enabled: Boolean(keyword && keyword.trim().length > 0),
+ });
+
+ return { data, isLoading, error };
+};
diff --git a/src/lib/hooks/useGetStockDetail.ts b/src/lib/hooks/useGetStockDetail.ts
new file mode 100644
index 0000000..738cff2
--- /dev/null
+++ b/src/lib/hooks/useGetStockDetail.ts
@@ -0,0 +1,12 @@
+import type { Period } from "@/_MarketDetailPage/types/stockDataType";
+import { getStockDetail } from "@/lib/apis/getStockDetail";
+import { useQuery } from "@tanstack/react-query";
+
+export const useGetStockDetail = (stockCode: string, period: Period) => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["stockDetail", stockCode, period],
+ queryFn: () => getStockDetail(stockCode, period),
+ });
+
+ return { data, isLoading, error };
+};
diff --git a/src/lib/hooks/useGetStockList.ts b/src/lib/hooks/useGetStockList.ts
new file mode 100644
index 0000000..0e9a9d5
--- /dev/null
+++ b/src/lib/hooks/useGetStockList.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { getStockList } from "@/lib/apis/getStockList";
+
+export const useGetStockList = (page: number, size: number) => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["stockList", page, size],
+ queryFn: () => getStockList(page, size),
+ });
+
+ return { data, isLoading, error };
+};
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/lib/queryClient.ts b/src/lib/queryClient.ts
new file mode 100644
index 0000000..e689242
--- /dev/null
+++ b/src/lib/queryClient.ts
@@ -0,0 +1,24 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ // 기본 staleTime: 5분 (5분 동안은 fresh한 데이터로 간주)
+ staleTime: 1000 * 60 * 5,
+ // 기본 cacheTime: 10분 (10분 동안 캐시 유지)
+ gcTime: 1000 * 60 * 10,
+ // 에러 발생 시 재시도 횟수
+ retry: 1,
+ // 재시도 간격
+ retryDelay: 1000,
+ // 백그라운드에서 자동으로 데이터 refetch 비활성화 (필요시 조정)
+ refetchOnWindowFocus: false,
+ // 네트워크 재연결 시 자동 refetch
+ refetchOnReconnect: true,
+ },
+ mutations: {
+ // mutation 실패 시 재시도 횟수
+ retry: 0,
+ },
+ },
+});
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index a5ef193..502c38b 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+/**
+ * 숫자를 천 단위 구분자(쉼표)가 포함된 문자열로 포맷팅
+ * @param value - 포맷팅할 숫자
+ * @returns 포맷팅된 문자열 (예: 1000 -> "1,000")
+ */
+export function formatNumber(value: number | string | null | undefined): string {
+ if (value === null || value === undefined) return "";
+ const num = typeof value === "string" ? parseFloat(value) : value;
+ if (isNaN(num)) return String(value);
+ return num.toLocaleString("ko-KR");
+}
diff --git a/src/main.tsx b/src/main.tsx
index b331976..9b262ea 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import "./styles/tailwind.css";
-import App from "./App.tsx";
+import "@/styles/tailwind.css";
+import App from "@/App";
createRoot(document.getElementById("root")!).render(
diff --git a/src/pages/BacktestingPage.tsx b/src/pages/BacktestingPage.tsx
index f33143e..18129e2 100644
--- a/src/pages/BacktestingPage.tsx
+++ b/src/pages/BacktestingPage.tsx
@@ -9,9 +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 { 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 }]);
@@ -22,12 +29,47 @@ 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);
+
+ // 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 || "백테스트 수행 중 오류가 발생했습니다."
+ : "";
+
+ // 에러나 결과가 나오면 스크롤을 아래로 내리기
+ 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;
});
@@ -42,42 +84,69 @@ 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 (
-
+
-
-
-
+
+
+
+
+
+
+
+
+ {/* 로딩 상태 또는 Progress 진행 중 - 전체 화면 overlay */}
+ {(isPending || (progress > 0 && progress < 100)) && (
+
+ )}
+
+ {/* 에러 상태 - error가 있거나 data.isSuccess가 false인 경우 */}
+ {hasError && !isPending && progress === 0 && (
+
+
+
+
+
+
+ 백테스트 수행 중 오류가 발생했습니다
+
+
{errorMessage}
+
+
+
+
+
+ )}
+
+ {/* 성공 상태 - Progress가 100%가 되고 showResult가 true일 때만 렌더링 */}
+ {showResult && !error && data?.isSuccess && data?.result && (
+
+
+
+ )}
);
};
diff --git a/src/pages/MarketDetailPage.tsx b/src/pages/MarketDetailPage.tsx
index e90dbd7..67e7bce 100644
--- a/src/pages/MarketDetailPage.tsx
+++ b/src/pages/MarketDetailPage.tsx
@@ -1,47 +1,106 @@
-import { useState, useEffect, useMemo } from "react";
-import type { StockData } from "../_MarketDetailPage/types/stockDataType";
+import { useState, useMemo } from "react";
+import { useParams } from "react-router-dom";
+import { type Period, type ChartType } from "@/_MarketDetailPage/types/stockDataType";
import DetailItem from "@/_MarketDetailPage/components/DetailItem";
-import { sampleData } from "../_MarketDetailPage/datas/stockSample";
+import StockChart from "@/_MarketDetailPage/components/StockChart";
+import { useGetStockDetail } from "@/lib/hooks/useGetStockDetail";
+import { formatNumber } from "@/lib/utils";
+import ChartFilterBar from "@/_MarketDetailPage/components/ChartFilterBar";
+import { Spinner } from "@/components/ui/spinner";
+
const MarketDetailPage = () => {
- const [stockData, setStockData] = useState
(null);
+ const { code } = useParams<{ code: string }>();
+ const [period, setPeriod] = useState("1M");
+ const [chartType, setChartType] = useState("line");
+
+ // 항상 오늘 기준 7일 전 ~ 오늘 데이터 요청 (고정)
+ const { data: stockData, isLoading, error } = useGetStockDetail(code || "", "1W");
+
+ // API 데이터의 가장 최근 데이터와 그 직전 데이터 찾기
+ const { latestData, previousData } = useMemo(() => {
+ if (!stockData) {
+ return { latestData: null, previousData: null };
+ }
+
+ // stockPriceList 필드명이 다를 수 있으므로 여러 가능성 확인
+ const stockPriceList = stockData.stockPriceList;
+
+ if (!stockPriceList) {
+ return { latestData: null, previousData: null };
+ }
+
+ if (!Array.isArray(stockPriceList)) {
+ return { latestData: null, previousData: null };
+ }
+
+ if (stockPriceList.length === 0) {
+ return { latestData: null, previousData: null };
+ }
+
+ // API 데이터가 이미 현재-과거 순서로 오므로 정렬하지 않고 0, 1번째 인덱스 사용
+ const latest = stockPriceList[0] || null;
+ const previous = stockPriceList[1] || null;
- // 데이터 로딩 (실제로는 API 호출)
- useEffect(() => {
- setStockData(sampleData);
- }, []);
+ return {
+ latestData: latest,
+ previousData: previous,
+ };
+ }, [stockData]);
// 등락 정보에 따른 스타일 계산
const changeInfo = useMemo(() => {
- if (!stockData) return null;
- switch (stockData.change) {
- case "RISE":
- return { color: "text-red-500", icon: "▲" };
- case "FALL":
- return { color: "text-blue-500", icon: "▼" };
- default:
- return { color: "text-white", icon: null };
+ if (!stockData || !latestData) return null;
+
+ if (latestData.changeRate > 0) {
+ return { color: "text-red-500", icon: "▲" };
+ } else if (latestData.changeRate < 0) {
+ return { color: "text-blue-500", icon: "▼" };
+ } else {
+ return { color: "text-white", icon: null };
}
- }, [stockData]);
+ }, [stockData, latestData]);
+
+ // 로딩 상태
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // 에러 상태
+ if (error || !stockData) {
+ return (
+
+
+ {error ? "데이터를 불러오는 중 오류가 발생했습니다." : "데이터를 찾을 수 없습니다."}
+
+
+ );
+ }
- if (!stockData || !changeInfo) {
+ // 데이터가 없을 때
+ if (!changeInfo || !latestData || !previousData) {
return (
- Loading...
+
데이터가 없습니다.
);
}
+
const highPriceColor =
- stockData.highPrice > stockData.prevClosePrice ? "text-red-500" : "text-white";
+ latestData.highPrice > previousData.highPrice ? "text-red-500" : "text-white";
const lowPriceColor =
- stockData.lowPrice < stockData.prevClosePrice ? "text-blue-500" : "text-white";
+ latestData.lowPrice < previousData.lowPrice ? "text-blue-500" : "text-white";
return (
-
+
{/* 상단: 종목명 및 코드 */}
-
{stockData.name}
-
{stockData.code}
+
{stockData.stockName}
+
{stockData.stockCode}
@@ -50,51 +109,52 @@ const MarketDetailPage = () => {
{/* 좌측: 현재가 정보 */}
- {stockData.currentPrice.toLocaleString()}
+ {formatNumber(latestData.closePrice)}
전일대비
{changeInfo.icon}
- {stockData.changeAmount.toLocaleString()}
- ({stockData.changeRate.toFixed(2)}%)
+ {formatNumber(latestData.changeAmount)}
+ ({latestData.changeRate.toFixed(2)}%)
{/* 우측: 상세 거래 정보 */}
{/* 하단: 차트 */}
-
);
};
diff --git a/src/pages/MarketsPage.tsx b/src/pages/MarketsPage.tsx
index 3e5f8ee..e4493a3 100644
--- a/src/pages/MarketsPage.tsx
+++ b/src/pages/MarketsPage.tsx
@@ -1,45 +1,41 @@
-import { useState, useEffect, useMemo } from "react";
-import MarketList from "../_MarketsPage/components/MarketList";
-import { MOCK_DATA } from "../_MarketsPage/datas/MarketMockData";
-import type { MarketItem } from "../_MarketsPage/types/marketItem";
-import Pagination from "../components/Pagination";
-import Title from "../components/Title";
+import { useState, useMemo } from "react";
+import MarketList from "@/_MarketsPage/components/MarketList";
+import type { StockListResponse } from "@/_MarketsPage/types/marketItem";
+import Pagination from "@/components/Pagination";
+import Title from "@/components/Title";
+import { useGetStockList } from "@/lib/hooks/useGetStockList";
+import { Spinner } from "@/components/ui/spinner";
const ITEMS_PER_PAGE = 10;
-const CATEGORIES = ["거래대금", "거래량", "급상승", "급하락", "인기"];
const MarketsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
- const [marketData, setMarketData] = useState
([]);
- const [activeCategory, setActiveCategory] = useState("거래대금");
- // 주가 데이터 로딩
- useEffect(() => {
- // TODO: 백엔드 API 호출
- setMarketData(MOCK_DATA);
- }, []);
+ // 주가 데이터 로딩 (페이지는 0부터 시작하는 경우가 많으므로 currentPage - 1 전달)
+ const {
+ data: stockListResponse,
+ isLoading,
+ error,
+ } = useGetStockList(currentPage - 1, ITEMS_PER_PAGE);
- // 카테고리 변경 시 데이터 정렬 로직
- const sortedData = useMemo(() => {
- const data = [...marketData];
- switch (activeCategory) {
- case "급상승":
- return data.sort((a, b) => b.changeRate - a.changeRate);
- case "급하락":
- return data.sort((a, b) => a.changeRate - b.changeRate);
- // TODO: '거래대금', '거래량', '인기'에 대한 정렬 로직 구현
- case "거래대금":
- default:
- return data;
- }
- }, [activeCategory, marketData]);
+ // API 응답에서 content 추출 (타입 변환 없이 직접 사용)
+ const stockList = useMemo(() => {
+ if (!stockListResponse) return [];
+ const response = stockListResponse as StockListResponse;
+ return response.content || [];
+ }, [stockListResponse]);
- // 페이지네이션 계산
- const totalPages = Math.ceil(sortedData.length / ITEMS_PER_PAGE);
- const currentItems = sortedData.slice(
- (currentPage - 1) * ITEMS_PER_PAGE,
- currentPage * ITEMS_PER_PAGE
- );
+ // 전체 페이지 수 계산
+ const totalPages = useMemo(() => {
+ const response = stockListResponse as StockListResponse | undefined;
+ if (response?.totalPages !== undefined) {
+ return response.totalPages;
+ }
+ if (response?.totalElements !== undefined) {
+ return Math.ceil(response.totalElements / ITEMS_PER_PAGE);
+ }
+ return 0;
+ }, [stockListResponse]);
const handlePageChange = (page: number) => {
if (page > 0 && page <= totalPages) {
@@ -47,35 +43,42 @@ const MarketsPage = () => {
}
};
+ // 로딩 상태
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // 에러 상태
+ if (error) {
+ return (
+
+
데이터를 불러오는 중 오류가 발생했습니다.
+
+ );
+ }
+
return (
- {/* 카테고리 필터 */}
-
- {CATEGORIES.map((category) => (
-
- ))}
-
-
-
-
-
+
+ {totalPages > 0 && (
+
+ )}
);
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;
diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx
index fb92abd..50172ee 100644
--- a/src/routes/routes.tsx
+++ b/src/routes/routes.tsx
@@ -1,6 +1,6 @@
import { type RouteObject } from "react-router-dom";
-import Layout from "../layouts/Layout";
-import { MainPage, MarketsPage, MarketDetailPage, PortfolioPage, BacktestingPage } from "../pages";
+import Layout from "@/layouts/Layout";
+import { MainPage, MarketsPage, MarketDetailPage, PortfolioPage, BacktestingPage } from "@/pages";
export const routes: RouteObject[] = [
{
diff --git a/src/utils/instance.ts b/src/utils/instance.ts
new file mode 100644
index 0000000..f696539
--- /dev/null
+++ b/src/utils/instance.ts
@@ -0,0 +1,15 @@
+import { applyDevLoggingInterceptor } from "@/utils/interceptor";
+import { BACKEND_BASE_URL } from "@/constants/api";
+import axios from "axios";
+
+export const __DEV__ = process.env.NODE_ENV === "development";
+
+export const instance = axios.create({
+ baseURL: BACKEND_BASE_URL,
+ timeout: 5000,
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
+
+applyDevLoggingInterceptor(instance);
diff --git a/src/utils/interceptor.ts b/src/utils/interceptor.ts
new file mode 100644
index 0000000..0546b72
--- /dev/null
+++ b/src/utils/interceptor.ts
@@ -0,0 +1,43 @@
+/* eslint-disable no-console */
+import { type AxiosError, type AxiosInstance } from "axios";
+import { __DEV__ } from "./instance";
+import type { ApiErrorResponse } from "@/lib/apis/types";
+
+export const applyDevLoggingInterceptor = (instance: AxiosInstance) => {
+ instance.interceptors.request.use((config) => {
+ if (__DEV__) {
+ // 개발 환경에서만 로깅
+ console.log("🚀 [Request]", config.method?.toUpperCase(), config.url);
+ if (config.data) console.log("📦 [Request Data]", config.data);
+ if (config.params) console.log("🔍 [Request Params]", config.params);
+ }
+ return config;
+ });
+
+ instance.interceptors.response.use(
+ // 성공 응답 처리
+ (response) => {
+ if (__DEV__) {
+ console.log("✅ [Response]", response.status, response.config.url);
+ console.log("📄 [Response Data]", response.data);
+ }
+ return response;
+ },
+ // 에러 응답 처리
+ (error: AxiosError) => {
+ if (__DEV__) {
+ console.error("❌ [Axios Error]", error.message);
+ if (error.response) {
+ const errorData = error.response.data;
+ console.error("💥 [Error Response]", {
+ status: errorData?.status,
+ title: errorData?.title,
+ detail: errorData?.detail,
+ instance: errorData?.instance,
+ });
+ }
+ }
+ return Promise.reject(error);
+ }
+ );
+};