diff --git a/src/api/auth/loginApi.ts b/src/api/auth/loginApi.ts index 7686e89..001b6d3 100644 --- a/src/api/auth/loginApi.ts +++ b/src/api/auth/loginApi.ts @@ -68,6 +68,9 @@ export const login = async ( ? responseData.access_token : `Bearer ${responseData.access_token}`; + // 헤더에서 첫 로그인만 토스트를 띄우기 위한 플래그 + sessionStorage.setItem("just_logged_in", "1"); + return { token, user: responseData.user, diff --git a/src/api/stock/detail.ts b/src/api/stock/detail.ts index 7eb88ba..4115980 100644 --- a/src/api/stock/detail.ts +++ b/src/api/stock/detail.ts @@ -40,3 +40,23 @@ export async function getStockChart( ); return data; } + +// Total analysis (LLM) API +export interface TotalAnalysisResponse { + main_analysis?: string; + volatility_analysis?: string; + volume_analysis?: string; + fin_total_analysis?: string; + company_analysis?: string; + total_analysis?: string; + combined_technical_analysis?: string; +} + +export async function getTotalAnalysis( + stockCode: string +): Promise { + const { data } = await axiosInstance.get( + `/api/info/total_analysis/${encodeURIComponent(stockCode)}` + ); + return data; +} diff --git a/src/components/Intro/ABTestResults.tsx b/src/components/Intro/ABTestResults.tsx new file mode 100644 index 0000000..f540b18 --- /dev/null +++ b/src/components/Intro/ABTestResults.tsx @@ -0,0 +1,233 @@ +import { motion } from "framer-motion"; + +export default function ABTestResults() { + return ( +
+
+ + + 📊 LLM 기반 금융 분석 모델 A/B 테스트 결과 + + + CoT/RAG 방식의 우수성을 과학적으로 검증했습니다 + + + {/* 기술 스택 비교 */} + +
+

+ Baseline +

+
+
+ GPT-4o +
+
기본 모델
+
+
+
+

+ Ours +

+
+
+ GPT-4o +
+
+ + BarbellAI 기술 +
+
+ Chain of Thought + RAG +
+
+
+
+
+ + + {/* 종합 분석 평가 */} + +

+ 🎯 종합 분석 평가 (깊이+근거+통찰력 평균) +

+
+
+
+ 6.92 +
+
CoT/RAG 평균 점수
+
+
+
+ 5.25 +
+
Baseline 평균 점수
+
+
+
+
+ +31.79% +
+
성능 향상률
+
+ P-value: 0.0000 (p < 0.0001) +
+
+
+ + {/* 개별 항목 평가 */} +
+ {/* 분석의 깊이 */} + +

+ 🔍 분석의 깊이 +

+
+
+ CoT/RAG + 7.53 +
+
+ Baseline + 5.28 +
+
+
+
+ +42.61% +
+
향상률
+
+
+
+
+ + {/* 논리적 근거 */} + +

+ 🧠 논리적 근거 +

+
+
+ CoT/RAG + 6.61 +
+
+ Baseline + 5.59 +
+
+
+
+ +18.25% +
+
향상률
+
+
+
+
+ + {/* 통찰력 */} + +

+ 💡 통찰력 +

+
+
+ CoT/RAG + 6.63 +
+
+ Baseline + 4.89 +
+
+
+
+ +35.58% +
+
향상률
+
+
+
+
+
+ + {/* 결론 */} + +
+

🏆 결론

+

+ CoT/RAG 방식이 모든 평가 항목에서 통계적으로 유의미하게 우수한 + 성능을 보여주었습니다. +
+ p < 0.001 수준에서 + 통계적 유의성을 확인했습니다. +

+
+
+
+
+
+ ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index bad8298..619f93f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import BALLFiNLogo from "../../assets/BALLFiN.svg"; import { Link, useLocation } from "react-router-dom"; import { Menu, X, LogOut, User, Settings } from "lucide-react"; @@ -40,6 +40,7 @@ const Header = () => { message: string; type: "success" | "error"; }>({ show: false, message: "", type: "success" }); + const wasLoggedInRef = useRef(false); useEffect(() => { // 로그인 상태 및 사용자 정보 확인 @@ -47,6 +48,17 @@ const Header = () => { const token = localStorage.getItem("access_token"); if (token) { + // 첫 로그인 플래그 확인 + const justLoggedIn = sessionStorage.getItem("just_logged_in"); + if (justLoggedIn) { + setToast({ + show: true, + message: "로그인되었습니다.", + type: "success", + }); + sessionStorage.removeItem("just_logged_in"); + } + wasLoggedInRef.current = true; setIsLoggedIn(true); // 먼저 localStorage에서 사용자 정보 확인 @@ -81,11 +93,13 @@ const Header = () => { setIsLoggedIn(false); setUserName(""); setUserEmail(""); + wasLoggedInRef.current = false; } } else { setIsLoggedIn(false); setUserName(""); setUserEmail(""); + wasLoggedInRef.current = false; } }; diff --git a/src/components/stockDetail/FinancialStatement.tsx b/src/components/stockDetail/FinancialStatement.tsx index 5938328..319c9f1 100644 --- a/src/components/stockDetail/FinancialStatement.tsx +++ b/src/components/stockDetail/FinancialStatement.tsx @@ -14,6 +14,7 @@ interface FinancialData { interface FinancialStatementProps { data: FinancialData; analysis?: any; // /info/company/{code} 응답 객체 (company_analysis 포함) + isAnalysisLoading?: boolean; // LLM 분석 로딩 상태 } interface FinancialIndicator { @@ -30,10 +31,11 @@ interface FinancialIndicator { export default function FinancialStatement({ data, analysis, + isAnalysisLoading: _isAnalysisLoading = false, }: FinancialStatementProps) { const [hoveredIndicator, setHoveredIndicator] = useState(null); - const company = analysis?.company_analysis; + const company = analysis?.company_data; // 재무 데이터 const isAnalysisLoading = !company; // 간단 스켈레톤 텍스트 @@ -296,14 +298,17 @@ export default function FinancialStatement({

종합 해석

- {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
) : (

- {safeText(company?.total_analysis)} + {safeText( + analysis?.company_analysis ?? + "재무 분석 정보를 불러오고 있습니다." + )}

)} diff --git a/src/components/stockDetail/TechnicalAnalysis.tsx b/src/components/stockDetail/TechnicalAnalysis.tsx index 1bcb525..2640924 100644 --- a/src/components/stockDetail/TechnicalAnalysis.tsx +++ b/src/components/stockDetail/TechnicalAnalysis.tsx @@ -34,6 +34,8 @@ interface TechnicalAnalysisProps { stock: StockDetail; historicalData: HistoricalData[]; analysis?: any; // /info/company/{code} 응답 전체 또는 필요한 섹션 포함 객체 + isAnalysisLoading?: boolean; // LLM 분석 로딩 상태 + isTechnicalLoading?: boolean; // 기술적 분석 데이터 로딩 상태 } type TabType = "summary" | "volatility" | "volume" | "overview"; @@ -42,6 +44,8 @@ export default function TechnicalAnalysis({ stock: _stock, historicalData: _historicalData, analysis, + isAnalysisLoading: _isAnalysisLoading = false, + isTechnicalLoading: _isTechnicalLoading = true, }: TechnicalAnalysisProps) { const [activeTab, setActiveTab] = useState("summary"); const [hoveredKey, setHoveredKey] = useState(null); @@ -68,26 +72,34 @@ export default function TechnicalAnalysis({ return "text-blue-600"; }; - const main = analysis?.main_analysis; - const vola = analysis?.volatility_analysis; - const vol = analysis?.volume_analysis; + // API 응답이 문자열이므로 직접 사용 + const mainAnalysisText = analysis?.main_analysis; + const volatilityAnalysisText = analysis?.volatility_analysis; + const volumeAnalysisText = analysis?.volume_analysis; + const combinedAnalysisText = analysis?.combined_technical_analysis; + + // 기존 기술적 지표 데이터 + const mainData = analysis?.main_analysis_data; + const volaData = analysis?.volatility_analysis_data; + const volData = analysis?.volume_analysis_data; const isAnalysisLoading = !analysis; - // 기술적 지표 값 (없으면 기본값) - const rsi = main?.rsi?.value ?? 28.4; - const dailyRange = vola?.volatility?.value?.volatility_percent ?? 2.8; - const avgVolatility = vola?.volatility?.value?.avg_volatility_percent ?? 2.8; - const currentVolume = toNum(vol?.volume?.value?.volume) || 15234567; - const avgVolume = toNum(vol?.volume?.value?.avg_volume_20) || 12456789; + // 기술적 지표 값 (실제 데이터 사용) + const rsi = mainData?.rsi?.value ?? 28.4; + const dailyRange = volaData?.volatility?.value?.volatility_percent ?? 2.8; + const avgVolatility = + volaData?.volatility?.value?.avg_volatility_percent ?? 2.8; + const currentVolume = toNum(volData?.volume?.value?.volume) || 15234567; + const avgVolume = toNum(volData?.volume?.value?.avg_volume_20) || 12456789; const volumeRatio = ( ((currentVolume - avgVolume) / (avgVolume || 1)) * 100 ).toFixed(1); const mfiValue = - typeof vol?.mfi?.value === "number" ? vol.mfi.value : undefined; - const obvValue = toNum(vol?.obv?.value?.obv); - const obvMa20Value = toNum(vol?.obv?.value?.obv_ma20); + typeof volData?.mfi?.value === "number" ? volData.mfi.value : undefined; + const obvValue = toNum(volData?.obv?.value?.obv); + const obvMa20Value = toNum(volData?.obv?.value?.obv_ma20); const tabs = [ { id: "summary", label: "주요 지표", icon: Target }, @@ -113,20 +125,21 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (main?.moving_average?.arrangement?.status ?? "정보 없음") + (mainData?.moving_average?.arrangement?.status ?? + "정보 없음") )}
{hoveredKey === "summary_ma" && (
- {main?.moving_average?.arrangement?.description ?? - main?.moving_average?.price_vs_ma20?.description ?? - ""} + {mainData?.moving_average?.arrangement?.description ?? + mainData?.moving_average?.price_vs_ma20?.description ?? + "이동평균선 분석 정보"}
@@ -144,17 +157,19 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (main?.stochastic?.status ?? "정보 없음") + (mainData?.stochastic?.status ?? "정보 없음") )}
{hoveredKey === "summary_stoch" && (
-
{main?.stochastic?.analysis ?? ""}
+
+ {mainData?.stochastic?.analysis ?? "스토캐스틱 분석 정보"} +
)} @@ -169,12 +184,12 @@ export default function TechnicalAnalysis({ RSI
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (main?.rsi?.status ?? "중립") + (mainData?.rsi?.status ?? "중립") )}
{hoveredKey === "summary_rsi" && ( @@ -196,17 +211,17 @@ export default function TechnicalAnalysis({
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (main?.rsi?.status ?? "중립") + (mainData?.macd?.status ?? "중립") )}
{hoveredKey === "summary_total" && (
-
{main?.macd?.analysis ?? ""}
+
{mainData?.macd?.analysis ?? "종합 신호 분석"}
)} @@ -218,14 +233,14 @@ export default function TechnicalAnalysis({ 주요지표 분석 - {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
) : (

- {main?.total_analysis ?? "분석 정보를 불러오고 있습니다."} + {mainAnalysisText ?? "분석 정보를 불러오고 있습니다."}

)} @@ -287,13 +302,7 @@ export default function TechnicalAnalysis({ {hoveredKey === "vol_avg" && (
-
- RVI:{" "} - {typeof vola?.rvi?.value?.rvi === "number" - ? vola.rvi.value.rvi.toFixed(4) - : "-"}{" "} - | ATR: {vola?.atr?.value?.atr ?? "-"} -
+
RVI: - | ATR: -
)} @@ -311,17 +320,17 @@ export default function TechnicalAnalysis({ ATR
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (vola?.atr?.status ?? "정보 없음") + (volaData?.atr?.status ?? "정보 없음") )}
{hoveredKey === "vol_atr" && (
-
{vola?.atr?.analysis ?? ""}
+
{volaData?.atr?.analysis ?? "ATR 분석 정보"}
)} @@ -336,17 +345,17 @@ export default function TechnicalAnalysis({ RVI
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (vola?.rvi?.status ?? "정보 없음") + (volaData?.rvi?.status ?? "정보 없음") )}
{hoveredKey === "vol_rvi" && (
-
{vola?.rvi?.analysis ?? ""}
+
{volaData?.rvi?.analysis ?? "RVI 분석 정보"}
)} @@ -359,15 +368,14 @@ export default function TechnicalAnalysis({ 변동성 분석 - {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
) : (

- {vola?.total_analysis ?? - vola?.volatility?.analysis ?? + {volatilityAnalysisText ?? "변동성 분석 정보를 불러오고 있습니다."}

)} @@ -429,12 +437,12 @@ export default function TechnicalAnalysis({ MFI
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (vol?.mfi?.status ?? "정보 없음") + (volData?.mfi?.status ?? "정보 없음") )}
{hoveredKey === "vol_mfi_card" && ( @@ -444,7 +452,7 @@ export default function TechnicalAnalysis({ ? `값 ${mfiValue.toFixed(2)}` : ""} -
{vol?.mfi?.analysis ?? ""}
+
{volData?.mfi?.analysis ?? "MFI 분석 정보"}
)} @@ -459,12 +467,12 @@ export default function TechnicalAnalysis({ OBV
- {isAnalysisLoading ? ( + {_isTechnicalLoading ? ( ) : ( - (vol?.obv?.status ?? "정보 없음") + (volData?.obv?.status ?? "정보 없음") )}
{hoveredKey === "vol_obv_card" && ( @@ -473,7 +481,7 @@ export default function TechnicalAnalysis({ {obvValue ? obvValue.toLocaleString() : "-"} / MA20{" "} {obvMa20Value ? obvMa20Value.toLocaleString() : "-"} -
{vol?.obv?.analysis ?? ""}
+
{volData?.obv?.analysis ?? "OBV 분석 정보"}
)} @@ -486,15 +494,14 @@ export default function TechnicalAnalysis({
거래량 분석 - {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
) : (

- {vol?.total_analysis ?? - vol?.volume?.analysis ?? + {volumeAnalysisText ?? `평균 대비 ${volumeRatio}% 수준의 거래량입니다.`}

)} @@ -512,14 +519,14 @@ export default function TechnicalAnalysis({ 기술적 분석 종합 해석 - {isAnalysisLoading ? ( + {_isAnalysisLoading ? (
) : (

- {analysis?.fin_total_analysis ?? + {combinedAnalysisText ?? "종합 분석 정보를 불러오고 있습니다."}

)} diff --git a/src/components/stockDetail/chart/PriceVolumeChart.tsx b/src/components/stockDetail/chart/PriceVolumeChart.tsx index dea63a2..1e3c1ca 100644 --- a/src/components/stockDetail/chart/PriceVolumeChart.tsx +++ b/src/components/stockDetail/chart/PriceVolumeChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, memo } from "react"; import Highcharts from "highcharts/highstock"; import HighchartsReact from "highcharts-react-official"; import { TimeRangePT } from "."; @@ -12,38 +12,40 @@ export interface PriceVolumeChartProps { showMA: Record<"ma5" | "ma20" | "ma60" | "ma120", boolean>; } -export default function PriceVolumeChart({ +const PriceVolumeChart = memo(function PriceVolumeChart({ data, timeRange, showMA, }: PriceVolumeChartProps) { - // 초기부터 렌더링하여 차트가 안 보이는 문제 방지 - const [ready, setReady] = useState(true); + // TradingView 차트 우선 사용 + const [ready, setReady] = useState(false); const tvContainerRef = useRef(null); const [tvReady, setTvReady] = useState(false); + const [tvFailed, setTvFailed] = useState(false); - // Annotations 모듈 안전 로딩 (환경별 export 방식 대응) + // TradingView 실패 시에만 Highcharts 모듈 로딩 useEffect(() => { - (async () => { - try { - const mod: any = await import("highcharts/modules/annotations"); - const initFn = - typeof mod === "function" - ? mod - : typeof mod?.default === "function" - ? mod.default - : null; - if (initFn) { - initFn(Highcharts); + if (tvFailed) { + (async () => { + try { + const mod: any = await import("highcharts/modules/annotations"); + const initFn = + typeof mod === "function" + ? mod + : typeof mod?.default === "function" + ? mod.default + : null; + if (initFn) { + initFn(Highcharts); + } + } catch (_) { + // 실패해도 치명적이지 않음 + } finally { + setReady(true); } - } catch (_) { - // 실패해도 치명적이지 않음 - } finally { - // 모듈 로딩 실패/성공과 무관하게 이미 렌더링 중 - setReady(true); - } - })(); - }, []); + })(); + } + }, [tvFailed]); // TradingView Lightweight Charts 렌더링 (가능하면 우선 사용) useEffect(() => { @@ -55,6 +57,14 @@ export default function PriceVolumeChart({ let ma60Series: any | null = null; let ma120Series: any | null = null; + // TradingView 로딩 타임아웃 (3초) + const timeoutId = setTimeout(() => { + if (!tvReady && !tvFailed) { + console.log("TradingView 차트 로딩 타임아웃, Highcharts로 폴백"); + setTvFailed(true); + } + }, 3000); + (async () => { if (!tvContainerRef.current) return; try { @@ -161,19 +171,25 @@ export default function PriceVolumeChart({ chart.timeScale().fitContent(); setTvReady(true); - } catch { + setTvFailed(false); + clearTimeout(timeoutId); // 성공 시 타임아웃 클리어 + } catch (error) { + console.error("TradingView 차트 로딩 실패:", error); setTvReady(false); + setTvFailed(true); + clearTimeout(timeoutId); // 실패 시 타임아웃 클리어 } })(); return () => { + clearTimeout(timeoutId); // 컴포넌트 언마운트 시 타임아웃 클리어 if (chart && tvContainerRef.current) { try { chart.remove?.(); } catch {} } }; - }, [data, showMA]); + }, [data, showMA, tvReady, tvFailed]); // timeRange 에 따른 데이터 필터링 및 Point 생성 const { @@ -274,28 +290,36 @@ export default function PriceVolumeChart({ }; }, [data, timeRange]); - // 3) 차트 옵션 + // 3) 차트 옵션 (최적화) const options: Highcharts.Options = useMemo(() => { return { chart: { zoomType: "x", backgroundColor: "#ffffff", height: 500, + animation: { + duration: 500, // 부드러운 전환을 위한 애니메이션 + easing: "easeInOutCubic", + }, + reflow: false, // 리플로우 비활성화 }, accessibility: { enabled: false }, title: { text: "" }, xAxis: { type: "datetime", - crosshair: true, + crosshair: false, // 크로스헤어 비활성화로 성능 향상 gridLineWidth: 1, gridLineColor: "#f1f5f9", + labels: { + step: Math.max(1, Math.floor(ohlcData.length / 10)), // 라벨 수 줄이기 + }, }, yAxis: [ { title: { text: "" }, height: "65%", lineWidth: 2, - crosshair: true, + crosshair: false, // 크로스헤어 비활성화 opposite: true, gridLineColor: "#eef2f7", labels: { style: { color: "#475569" } }, @@ -314,6 +338,7 @@ export default function PriceVolumeChart({ tooltip: { shared: false, split: true, + animation: false, // 툴팁 애니메이션 비활성화 }, series: [ { @@ -328,6 +353,11 @@ export default function PriceVolumeChart({ upLineColor: "#16a34a", pointPadding: 0, dataGrouping: { enabled: false }, + animation: { + duration: 500, + easing: "easeInOutCubic", + }, // 시리즈 애니메이션 활성화 + enableMouseTracking: true, }, { type: "column", @@ -393,18 +423,49 @@ export default function PriceVolumeChart({ lastClose, ]); - if (!ready) { - return
; + // TradingView 차트 우선 렌더링 + if (tvReady) { + return ( +
+ ); } - return tvReady ? ( -
- ) : ( - + // TradingView 실패 시 Highcharts 사용 + if (tvFailed && ready) { + return ( +
+ +
+ ); + } + + // TradingView 로딩 중이지만 데이터가 있으면 Highcharts로 폴백 + if (data.length > 0 && !tvReady && !tvFailed) { + return ( +
+ +
+ ); + } + + // 로딩 중 + return ( +
); -} +}); + +export default PriceVolumeChart; diff --git a/src/components/stockDetail/chart/StockChartHeader.tsx b/src/components/stockDetail/chart/StockChartHeader.tsx index 7ddcfdb..9838448 100644 --- a/src/components/stockDetail/chart/StockChartHeader.tsx +++ b/src/components/stockDetail/chart/StockChartHeader.tsx @@ -1,13 +1,18 @@ -import { dayTable, miniteTable } from '@/config/chart'; -import { TimeRangePT } from '.'; +import { dayTable, miniteTable } from "@/config/chart"; +import { TimeRangePT } from "."; interface StockChartHeader { timeRange: TimeRangePT; onTimeRangeChange: (r: TimeRangePT) => void; - showMA: Record<'ma5' | 'ma20' | 'ma60' | 'ma120', boolean>; - onToggleMA: (maType: 'ma5' | 'ma20' | 'ma60' | 'ma120') => void; + showMA: Record<"ma5" | "ma20" | "ma60" | "ma120", boolean>; + onToggleMA: (maType: "ma5" | "ma20" | "ma60" | "ma120") => void; } -export default function StockChartHeader({ showMA, timeRange, onTimeRangeChange, onToggleMA }: StockChartHeader) { +export default function StockChartHeader({ + showMA, + timeRange, + onTimeRangeChange, + onToggleMA, +}: StockChartHeader) { return (
@@ -17,33 +22,41 @@ export default function StockChartHeader({ showMA, timeRange, onTimeRangeChange, {/* 이동평균선 토글 버튼들 */}