diff --git a/package-lock.json b/package-lock.json index 8b00107..0be2a9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "framer-motion": "^12.6.2", "highcharts": "^12.3.0", "highcharts-react-official": "^3.2.2", + "lightweight-charts": "^5.0.8", "lucide-react": "^0.483.0", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", @@ -4035,6 +4036,12 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5495,6 +5502,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightweight-charts": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.0.8.tgz", + "integrity": "sha512-dNBK5TlNcG78RUnxYRAZP4XpY5bkp3EE0PPjFFPkdIZ8RvnvL2JLgTb1BLh40trHhgJl51b1bCz8678GpnKvIw==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/package.json b/package.json index 898ed96..4cc0c32 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "framer-motion": "^12.6.2", "highcharts": "^12.3.0", "highcharts-react-official": "^3.2.2", + "lightweight-charts": "^5.0.8", "lucide-react": "^0.483.0", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", diff --git a/src/api/auth/loginApi.ts b/src/api/auth/loginApi.ts index fb11fa9..7686e89 100644 --- a/src/api/auth/loginApi.ts +++ b/src/api/auth/loginApi.ts @@ -32,7 +32,7 @@ export const login = async ( ): Promise<{ token: string; user?: User }> => { try { const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/auth/login`, + `${import.meta.env.VITE_API_BASE_URL}/api/auth/login`, { method: "POST", headers: { @@ -92,7 +92,7 @@ export const verifyAuth = async (): Promise<{ } const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/auth/check`, + `${import.meta.env.VITE_API_BASE_URL}/api/auth/check`, { method: "GET", headers: { diff --git a/src/api/auth/logoutApi.ts b/src/api/auth/logoutApi.ts index 378ef5b..eaad12d 100644 --- a/src/api/auth/logoutApi.ts +++ b/src/api/auth/logoutApi.ts @@ -10,7 +10,7 @@ export const logout = async (): Promise => { } const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/auth/logout`, + `${import.meta.env.VITE_API_BASE_URL}/api/auth/logout`, { method: "POST", headers: { diff --git a/src/api/auth/signUpApi.ts b/src/api/auth/signUpApi.ts index 089f4c9..58abaa0 100644 --- a/src/api/auth/signUpApi.ts +++ b/src/api/auth/signUpApi.ts @@ -14,7 +14,7 @@ export const register = async ( ): Promise => { try { const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/auth/register`, + `${import.meta.env.VITE_API_BASE_URL}/api/auth/register`, { method: "POST", headers: { diff --git a/src/api/market/index.ts b/src/api/market/index.ts index 7df72c3..7c58137 100644 --- a/src/api/market/index.ts +++ b/src/api/market/index.ts @@ -32,6 +32,6 @@ export interface InterestRateWithHistory { } export const getAllMarketInfo = async () => { - const { data } = await axiosInstance.get("/info/all"); + const { data } = await axiosInstance.get("/api/info/all"); return data; }; diff --git a/src/api/news/byCompany.ts b/src/api/news/byCompany.ts index faa6fc9..a5de546 100644 --- a/src/api/news/byCompany.ts +++ b/src/api/news/byCompany.ts @@ -1,6 +1,4 @@ -import axios from "axios"; - -const API_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; +import { axiosInstance } from "@/lib/axiosInstance"; export interface CompanyNewsItemDTO { id: string; @@ -15,9 +13,11 @@ export interface CompanyNewsResponse { } export async function getNewsByCompany(stockCode: string, limit = 10) { - const url = `${API_URL}/news/by-company/${stockCode}`; - const { data } = await axios.get(url, { - params: { limit }, - }); + const { data } = await axiosInstance.get( + `/api/news/by-company/${stockCode}`, + { + params: { limit }, + } + ); return data.results; } diff --git a/src/api/news/detail.ts b/src/api/news/detail.ts index 8affed8..d9a1737 100644 --- a/src/api/news/detail.ts +++ b/src/api/news/detail.ts @@ -1,14 +1,13 @@ -import axios from "axios"; +import { axiosInstance } from "@/lib/axiosInstance"; import { NewsItem } from "./types"; - -const API_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; +import { isAxiosError } from "axios"; export const getNewsDetail = async (newsId: string): Promise => { try { - const response = await axios.get(`${API_URL}/news/${newsId}`); + const response = await axiosInstance.get(`/api/news/${newsId}`); return response.data; } catch (error) { - if (axios.isAxiosError(error)) { + if (isAxiosError(error)) { if (error.response?.status === 404) { throw new Error("해당 뉴스를 찾을 수 없습니다."); } diff --git a/src/api/news/feed.ts b/src/api/news/feed.ts index ebf2eb4..a7a4a90 100644 --- a/src/api/news/feed.ts +++ b/src/api/news/feed.ts @@ -1,7 +1,6 @@ -import axios from "axios"; +import { axiosInstance } from "@/lib/axiosInstance"; import { NewsItem } from "./types"; - -const API_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; +import { isAxiosError } from "axios"; export interface MyFeedParams { limit?: number; @@ -16,57 +15,22 @@ export const getMyFeed = async ( params: MyFeedParams = {} ): Promise => { try { - const requestUrl = `${API_URL}/news/my-feed`; const requestParams = { limit: params.limit || 20, }; - const tokenKey = "access_token"; - let token = localStorage.getItem(tokenKey); - if (token && !token.startsWith("Bearer ")) { - token = `Bearer ${token}`; - } - - console.log("API 요청 정보:", { - url: requestUrl, - params: requestParams, - env: { - VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL, - NODE_ENV: import.meta.env.NODE_ENV, - }, - headers: { - Authorization: token, - }, - }); - - const response = await axios.get(requestUrl, { - params: requestParams, - headers: { - Authorization: token, - }, - }); - - console.log("API 응답:", { - status: response.status, - data: response.data, - headers: response.headers, - }); + const response = await axiosInstance.get( + "/api/news/my-feed", + { + params: requestParams, + } + ); return response.data.results; } catch (error) { - console.error("API 에러 상세:", { - error, - isAxiosError: axios.isAxiosError(error), - response: axios.isAxiosError(error) - ? { - status: error.response?.status, - data: error.response?.data, - headers: error.response?.headers, - } - : null, - }); + console.error("API 에러 상세:", error); - if (axios.isAxiosError(error)) { + if (isAxiosError(error)) { // 404 Not Found - 즐겨찾기 종목이 없는 경우 if (error.response?.status === 404) { const detail = error.response.data?.detail; diff --git a/src/api/news/search.ts b/src/api/news/search.ts index 3d3ddb6..20c3bca 100644 --- a/src/api/news/search.ts +++ b/src/api/news/search.ts @@ -1,11 +1,9 @@ -import axios from "axios"; +import { axiosInstance } from "@/lib/axiosInstance"; import { NewsSearchParams } from "./types"; -const API_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; - export const searchNews = async (params: NewsSearchParams): Promise => { try { - const response = await axios.get(`${API_URL}/news/search`, { + const response = await axiosInstance.get("/api/news/search", { params: { keyword: params.keyword, impact: params.impact, diff --git a/src/api/stock/company.ts b/src/api/stock/company.ts index 18f8a9e..96996ff 100644 --- a/src/api/stock/company.ts +++ b/src/api/stock/company.ts @@ -5,6 +5,6 @@ export type CompanyInfoResponse = Record; export const getCompanyInfoByCode = async ( stockCode: string ): Promise => { - const { data } = await axiosInstance.get(`/info/company/${stockCode}`); + const { data } = await axiosInstance.get(`/api/info/company/${stockCode}`); return data; }; diff --git a/src/api/stock/detail.ts b/src/api/stock/detail.ts index 012a231..7eb88ba 100644 --- a/src/api/stock/detail.ts +++ b/src/api/stock/detail.ts @@ -6,6 +6,37 @@ export type RawStockInfoResponse = Record; export const getStockInfoByCode = async ( stockCode: string ): Promise => { - const { data } = await axiosInstance.get(`/info/stock/${stockCode}`); + const { data } = await axiosInstance.get(`/api/info/stock/${stockCode}`); return data; }; + +// 차트 데이터 타입 및 API +export interface StockChartCandle { + date: string; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export type StockChartPeriod = "D" | "W" | "M"; + +export interface StockChartResponse { + stock_code: string; + period: StockChartPeriod; + count: number; + candles: StockChartCandle[]; +} + +export async function getStockChart( + stockCode: string, + period: StockChartPeriod, + count: number +): Promise { + const { data } = await axiosInstance.get( + `/api/stock/chart/${encodeURIComponent(stockCode)}`, + { params: { period, count } } + ); + return data; +} diff --git a/src/api/stock/list.ts b/src/api/stock/list.ts index 05f1148..427a578 100644 --- a/src/api/stock/list.ts +++ b/src/api/stock/list.ts @@ -55,7 +55,7 @@ export const getStockList = async ( ): Promise => { try { // 새로운 /info/companies API 사용 - const response = await axiosInstance.get("/info/companies", { + const response = await axiosInstance.get("/api/info/companies", { params: { sort_by: sortBy, }, diff --git a/src/api/stock/relatedCompanies.ts b/src/api/stock/relatedCompanies.ts index 0a8649a..06dfb43 100644 --- a/src/api/stock/relatedCompanies.ts +++ b/src/api/stock/relatedCompanies.ts @@ -17,7 +17,7 @@ export async function getRelatedCompanies( sort_by = "market_cap_desc" ) { const { data } = await axiosInstance.get( - `/info/related-companies/${stockCode}`, + `/api/info/related-companies/${stockCode}`, { params: { sort_by } } ); return data; diff --git a/src/api/stock/search.ts b/src/api/stock/search.ts index e562504..39452ce 100644 --- a/src/api/stock/search.ts +++ b/src/api/stock/search.ts @@ -29,7 +29,7 @@ export const searchStockData = async ( params: StockSearchParams ): Promise => { try { - const response = await axiosInstance.get("/stock/search", { params }); + const response = await axiosInstance.get("/api/stock/search", { params }); return response.data; } catch (error) { console.error("주식 검색 중 오류 발생:", error); diff --git a/src/api/user/favoritesApi.ts b/src/api/user/favoritesApi.ts index 421ce4b..15cbe62 100644 --- a/src/api/user/favoritesApi.ts +++ b/src/api/user/favoritesApi.ts @@ -1,7 +1,7 @@ // 즐겨찾기 목록 조회 export const getFavorites = async (): Promise => { try { - const response = await fetch("/user/favorites", { + const response = await fetch("/api/user/favorites", { method: "GET", headers: { "Content-Type": "application/json", @@ -22,7 +22,7 @@ export const getFavorites = async (): Promise => { // 즐겨찾기 추가 export const addFavorite = async (tickerOrCompany: string): Promise => { try { - const response = await fetch("/user/favorites", { + const response = await fetch("/api/user/favorites", { method: "POST", headers: { "Content-Type": "application/json", @@ -48,7 +48,7 @@ export const removeFavorite = async ( tickerOrCompany: string ): Promise => { try { - const response = await fetch("/user/favorites", { + const response = await fetch("/api/user/favorites", { method: "DELETE", headers: { "Content-Type": "application/json", diff --git a/src/components/home/PopularStocks.tsx b/src/components/home/PopularStocks.tsx index b0c310c..a0900be 100644 --- a/src/components/home/PopularStocks.tsx +++ b/src/components/home/PopularStocks.tsx @@ -38,7 +38,7 @@ interface Stock { // API 호출 함수 const getPopularCompanies = async (): Promise => { try { - const response = await fetch("/info/companies?sort_by=market_cap_desc"); + const response = await fetch("/api/info/companies?sort_by=market_cap_desc"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); diff --git a/src/components/home/TrendingKeywords.tsx b/src/components/home/TrendingKeywords.tsx index e5ded55..aaa03e5 100644 --- a/src/components/home/TrendingKeywords.tsx +++ b/src/components/home/TrendingKeywords.tsx @@ -28,7 +28,7 @@ const getCompanies = async ( sortBy: SortBy = "market_cap_desc" ): Promise => { try { - const response = await fetch(`/info/companies?sort_by=${sortBy}`); + const response = await fetch(`/api/info/companies?sort_by=${sortBy}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); diff --git a/src/components/stockDetail/chart/PriceVolumeChart.tsx b/src/components/stockDetail/chart/PriceVolumeChart.tsx index 4748cfe..dea63a2 100644 --- a/src/components/stockDetail/chart/PriceVolumeChart.tsx +++ b/src/components/stockDetail/chart/PriceVolumeChart.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Highcharts from "highcharts/highstock"; import HighchartsReact from "highcharts-react-official"; -import { HistoricalData, TimeRangePT } from "."; +import { TimeRangePT } from "."; +import type { HistoricalData } from "@/config/chart"; import { miniteTable } from "@/config/chart"; type Point = [number, number]; @@ -14,9 +15,12 @@ export interface PriceVolumeChartProps { export default function PriceVolumeChart({ data, timeRange, + showMA, }: PriceVolumeChartProps) { // 초기부터 렌더링하여 차트가 안 보이는 문제 방지 const [ready, setReady] = useState(true); + const tvContainerRef = useRef(null); + const [tvReady, setTvReady] = useState(false); // Annotations 모듈 안전 로딩 (환경별 export 방식 대응) useEffect(() => { @@ -41,12 +45,157 @@ export default function PriceVolumeChart({ })(); }, []); + // TradingView Lightweight Charts 렌더링 (가능하면 우선 사용) + useEffect(() => { + let chart: any | null = null; + let candleSeries: any | null = null; + let volumeSeries: any | null = null; + let ma5Series: any | null = null; + let ma20Series: any | null = null; + let ma60Series: any | null = null; + let ma120Series: any | null = null; + + (async () => { + if (!tvContainerRef.current) return; + try { + const tv = await import("lightweight-charts"); + const { createChart, CandlestickSeries, HistogramSeries, LineSeries } = + tv as any; + + chart = createChart(tvContainerRef.current, { + layout: { + textColor: "#334155", + background: { type: "solid", color: "#ffffff" }, + }, + grid: { + vertLines: { color: "#f1f5f9" }, + horzLines: { color: "#f1f5f9" }, + }, + rightPriceScale: { borderColor: "#e2e8f0" }, + timeScale: { + borderColor: "#e2e8f0", + fixLeftEdge: false, + fixRightEdge: false, + barSpacing: 2, + rightOffset: 0, + }, + autoSize: true, + }); + + candleSeries = chart.addSeries(CandlestickSeries, { + upColor: "#16a34a", + downColor: "#ef4444", + borderVisible: false, + wickUpColor: "#16a34a", + wickDownColor: "#ef4444", + }); + + volumeSeries = chart.addSeries(HistogramSeries, { + priceFormat: { type: "volume" }, + color: "#cbd5e1", + }); + // 상단 70% 가격, 하단 25% 거래량 영역 확보 + (candleSeries as any) + .priceScale() + .applyOptions({ scaleMargins: { top: 0.05, bottom: 0.3 } }); + (volumeSeries as any) + .priceScale() + .applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } }); + + const tvCandles = data.map((d) => ({ + time: d.date, // YYYY-MM-DD + open: d.open, + high: d.high, + low: d.low, + close: d.close, + })); + const tvVolumes = data.map((d) => ({ + time: d.date, + value: d.volume, + color: d.close >= d.open ? "#9bd3ae" : "#f3b6b6", + })); + + candleSeries.setData(tvCandles); + volumeSeries.setData(tvVolumes); + + // MA 시리즈 + const makeLine = (color: string) => + chart.addSeries(LineSeries, { + color, + priceLineVisible: false, + lastValueVisible: false, + lineWidth: 2, + }); + if (showMA.ma5) { + ma5Series = makeLine("#8b5cf6"); + ma5Series.setData( + data + .filter((d) => d.ma5 != null) + .map((d) => ({ time: d.date, value: d.ma5! })) + ); + } + if (showMA.ma20) { + ma20Series = makeLine("#10b981"); + ma20Series.setData( + data + .filter((d) => d.ma20 != null) + .map((d) => ({ time: d.date, value: d.ma20! })) + ); + } + if (showMA.ma60) { + ma60Series = makeLine("#fb923c"); + ma60Series.setData( + data + .filter((d) => d.ma60 != null) + .map((d) => ({ time: d.date, value: d.ma60! })) + ); + } + if (showMA.ma120) { + ma120Series = makeLine("#3b82f6"); + ma120Series.setData( + data + .filter((d) => d.ma120 != null) + .map((d) => ({ time: d.date, value: d.ma120! })) + ); + } + + chart.timeScale().fitContent(); + setTvReady(true); + } catch { + setTvReady(false); + } + })(); + + return () => { + if (chart && tvContainerRef.current) { + try { + chart.remove?.(); + } catch {} + } + }; + }, [data, showMA]); + // timeRange 에 따른 데이터 필터링 및 Point 생성 - const { priceData, volumeData } = useMemo(() => { + const { + ohlcData, + volumePoints, + ma5Data, + ma20Data, + ma60Data, + ma120Data, + lastClose, + } = useMemo(() => { const arr = data.map((d) => ({ ts: new Date(d.date).getTime(), + open: d.open, + high: d.high, + low: d.low, close: d.close, volume: d.volume, + ma5: d.ma5, + ma20: d.ma20, + ma60: d.ma60, + ma120: d.ma120, })); let filtered = arr; @@ -88,22 +237,49 @@ export default function PriceVolumeChart({ filtered = Object.values(byYear).sort((a, b) => a.ts - b.ts); } + const ohlcData = filtered.map( + (i) => + [i.ts, i.open, i.high, i.low, i.close] as unknown as [ + number, + number, + number, + number, + number, + ] + ); + const volumePoints = filtered.map((i) => ({ + x: i.ts, + y: i.volume, + color: (i.close ?? 0) >= (i.open ?? 0) ? "#9bd3ae" : "#f3b6b6", + })) as unknown as Highcharts.PointOptionsObject[]; + + const last = filtered[filtered.length - 1]; + return { - priceData: filtered.map((i) => [i.ts, i.close] as Point), - volumeData: filtered.map((i) => [i.ts, i.volume] as Point), + ohlcData, + volumePoints, + ma5Data: filtered + .filter((i) => i.ma5 !== undefined) + .map((i) => [i.ts, i.ma5 as number] as Point), + ma20Data: filtered + .filter((i) => i.ma20 !== undefined) + .map((i) => [i.ts, i.ma20 as number] as Point), + ma60Data: filtered + .filter((i) => i.ma60 !== undefined) + .map((i) => [i.ts, i.ma60 as number] as Point), + ma120Data: filtered + .filter((i) => i.ma120 !== undefined) + .map((i) => [i.ts, i.ma120 as number] as Point), + lastClose: last?.close, }; }, [data, timeRange]); // 3) 차트 옵션 const options: Highcharts.Options = useMemo(() => { - const len = priceData.length; - const start = len > 0 ? priceData[Math.max(0, len - 10)][0] : undefined; - const end = len > 0 ? priceData[len - 1][0] : undefined; - return { chart: { zoomType: "x", - backgroundColor: "#fff", + backgroundColor: "#ffffff", height: 500, }, accessibility: { enabled: false }, @@ -111,71 +287,121 @@ export default function PriceVolumeChart({ xAxis: { type: "datetime", crosshair: true, - ...(start !== undefined ? { min: start } : {}), - ...(end !== undefined ? { max: end } : {}), + gridLineWidth: 1, + gridLineColor: "#f1f5f9", }, yAxis: [ { - title: { text: "Price" }, + title: { text: "" }, height: "65%", lineWidth: 2, crosshair: true, + opposite: true, + gridLineColor: "#eef2f7", + labels: { style: { color: "#475569" } }, }, { - title: { text: "Volume" }, + title: { text: "" }, top: "67%", height: "30%", offset: 0, lineWidth: 2, + opposite: true, + gridLineColor: "#f1f5f9", + labels: { style: { color: "#64748b" } }, }, ], tooltip: { - shared: true, - split: false, + shared: false, + split: true, }, series: [ { - type: "line", + type: "candlestick", name: "Price", - data: priceData, + data: ohlcData as any, yAxis: 0, - lineWidth: 2, - marker: { enabled: false, radius: 3 }, - tooltip: { - pointFormatter() { - return `${Highcharts.dateFormat("%Y-%m-%d", this.x)}
가격: ${this.y}
`; - }, - }, + tooltip: { valueDecimals: 2 }, + color: "#ef4444", + lineColor: "#ef4444", + upColor: "#16a34a", + upLineColor: "#16a34a", + pointPadding: 0, + dataGrouping: { enabled: false }, }, { type: "column", name: "Volume", - data: volumeData, + data: volumePoints as any, yAxis: 1, - color: "#c0d9e3", + color: "#d1e3ea", pointPadding: 0.05, groupPadding: 0.02, borderWidth: 0, - tooltip: { - pointFormatter() { - return `거래량: ${this.y}
`; - }, - }, + tooltip: { valueDecimals: 0 }, + }, + { + type: "line", + name: "MA5", + data: ma5Data, + color: "#8b5cf6", + yAxis: 0, + visible: !!showMA.ma5, + tooltip: { valueDecimals: 2 }, + }, + { + type: "line", + name: "MA20", + data: ma20Data, + color: "#10b981", + yAxis: 0, + visible: !!showMA.ma20, + tooltip: { valueDecimals: 2 }, + }, + { + type: "line", + name: "MA60", + data: ma60Data, + color: "#fb923c", + yAxis: 0, + visible: !!showMA.ma60, + tooltip: { valueDecimals: 2 }, + }, + { + type: "line", + name: "MA120", + data: ma120Data, + color: "#3b82f6", + yAxis: 0, + visible: !!showMA.ma120, + tooltip: { valueDecimals: 2 }, }, ], rangeSelector: { enabled: false }, navigator: { enabled: false }, + scrollbar: { enabled: false }, credits: { enabled: false }, }; - }, [priceData, volumeData]); + }, [ + ohlcData, + volumePoints, + ma5Data, + ma20Data, + ma60Data, + ma120Data, + showMA, + lastClose, + ]); if (!ready) { return
; } - return ( + return tvReady ? ( +
+ ) : ( void; - onToggleMA: (maType: 'ma5' | 'ma20' | 'ma60' | 'ma120') => void; + timeRange: + | "1m" + | "5m" + | "15m" + | "30m" + | "1h" + | "1d" + | "1w" + | "1m" + | "3m" + | "1y"; + onTimeRangeChange: ( + range: "1m" | "5m" | "15m" | "30m" | "1h" | "1d" | "1w" | "1m" | "3m" | "1y" + ) => void; + onToggleMA: (maType: "ma5" | "ma20" | "ma60" | "ma120") => void; } const CustomTooltip = ({ active, payload, label }: any) => { @@ -33,41 +45,59 @@ const CustomTooltip = ({ active, payload, label }: any) => {

시가: - {data.open?.toLocaleString()}원 + + {data.open?.toLocaleString()}원 +

고가: - {data.high?.toLocaleString()}원 + + {data.high?.toLocaleString()}원 +

저가: - {data.low?.toLocaleString()}원 + + {data.low?.toLocaleString()}원 +

종가: - {data.close?.toLocaleString()}원 + + {data.close?.toLocaleString()}원 +

거래량: - {data.volume?.toLocaleString()} + + {data.volume?.toLocaleString()} +

{data.ma5 && (

MA5: - {data.ma5?.toLocaleString()}원 + + {data.ma5?.toLocaleString()}원 +

MA20: - {data.ma20?.toLocaleString()}원 + + {data.ma20?.toLocaleString()}원 +

MA60: - {data.ma60?.toLocaleString()}원 + + {data.ma60?.toLocaleString()}원 +

MA120: - {data.ma120?.toLocaleString()}원 + + {data.ma120?.toLocaleString()}원 +

)} @@ -89,7 +119,10 @@ const PriceChart = ({ return (
- + `${(value / 1000).toFixed(0)}K`} /> } /> {/* 주가 꺾은선 (빨간색) */} - + {/* 이동평균선들 */} {showMA.ma5 && ( - + )} {showMA.ma20 && ( - + )} {showMA.ma60 && ( - + )} {showMA.ma120 && ( - + )} @@ -133,7 +201,10 @@ const VolumeChart = ({ data }: { data: HistoricalData[] }) => { return (
- + { return `${date.getMonth() + 1}/${date.getDate()}`; }} /> - `${(value / 1000).toFixed(0)}K`} /> + `${(value / 1000).toFixed(0)}K`} + /> { if (active && payload && payload.length) { return (

{label}

-

거래량: {payload[0].value?.toLocaleString()}

+

+ 거래량: {payload[0].value?.toLocaleString()} +

); } return null; }} /> - +
); }; -export default function StockChart({ data, showMA, timeRange, onTimeRangeChange, onToggleMA }: StockChartProps) { +export default function StockChart({ + data, + showMA, + timeRange, + onTimeRangeChange, + onToggleMA, +}: StockChartProps) { return (
@@ -174,33 +261,41 @@ export default function StockChart({ data, showMA, timeRange, onTimeRangeChange, {/* 이동평균선 토글 버튼들 */}