diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py
index 1df3f0d..29c975f 100644
--- a/backend/app/api/endpoints/market_data.py
+++ b/backend/app/api/endpoints/market_data.py
@@ -8,6 +8,7 @@
"""
# 필요한 라이브러리 및 모듈 임포트
from datetime import datetime, timedelta
+import asyncio
import random
import logging
import pytz
@@ -87,19 +88,21 @@ async def get_stock_prices(codes: str):
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
prices: Dict[str, float] = {}
- # 각 종목 코드에 대해 순차적으로 현재가 조회
- # 클래스 내부에 API 호출 간격을 조절하는 쓰로틀링 기능이 내장되어 있음
- for code in code_list:
+ # 병렬로 현재가 일괄 조회 (asyncio.gather)
+ async def _fetch_one(code: str):
try:
data = await korea_investment_api.get_current_price(code)
if data and data.get("output"):
o = data["output"]
- # 콤마가 포함된 가격 문자열을 float으로 변환
- current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
- prices[code] = current_price
+ return code, float(str(o.get("stck_prpr", "0")).replace(",", ""))
except Exception as e:
- # 특정 종목 조회 실패 시 경고 로그 기록
logger.warning(f"Failed to fetch price for {code}: {e}")
+ return code, None
+
+ results = await asyncio.gather(*[_fetch_one(c) for c in code_list])
+ for code, price in results:
+ if price is not None:
+ prices[code] = price
# 조회 결과를 딕셔너리 형태로 반환
return {
diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py
index 1efc09f..4fdcf49 100644
--- a/backend/app/api/endpoints/portfolio.py
+++ b/backend/app/api/endpoints/portfolio.py
@@ -76,7 +76,7 @@ class PortfolioCache:
return False
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
return True
- if (now - self.last_synced) > timedelta(seconds=5): # 실시간: 5초마다 갱신
+ if (now - self.last_synced) > self.min_interval: # min_interval(15초)마다 갱신
return True
return False
@@ -331,9 +331,25 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
processed_codes = set()
new_holdings = []
if "output1" in balance_data:
+ # ── 현재가 병렬 조회 (N+1 순차 → asyncio.gather) ──────────────────
+ _prefetch_codes = [
+ item.get("pdno") for item in balance_data["output1"]
+ if item.get("pdno") not in ghost_candidates
+ and int(item.get("hldg_qty", 0)) > 0
+ ]
+ if _prefetch_codes:
+ _price_results = await asyncio.gather(
+ *[korea_investment_api.get_current_price(c) for c in _prefetch_codes],
+ return_exceptions=True,
+ )
+ _realtime_price_map = dict(zip(_prefetch_codes, _price_results))
+ else:
+ _realtime_price_map = {}
+ # ──────────────────────────────────────────────────────────────────
+
for item in balance_data["output1"]:
stock_code = item.get("pdno")
-
+
# 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리)
# 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음.
if stock_code in ghost_candidates:
@@ -349,23 +365,22 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if quantity <= 0:
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue
-
+
# KIS API 필드명 정확히 매핑 (로깅 추가)
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
- # 🔥 실시간 현재가 조회 (잔고 조회의 prpr은 실시간이 아님)
+ # 실시간 현재가 (병렬 조회 결과 참조)
+ price_resp = _realtime_price_map.get(stock_code)
try:
- price_resp = await korea_investment_api.get_current_price(stock_code)
- if price_resp and "output" in price_resp:
+ if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp:
realtime_price = int(price_resp["output"].get("stck_prpr", 0))
- if realtime_price > 0:
- current_price = Decimal(str(realtime_price))
- else:
- current_price = Decimal(str(item.get("prpr", 0)))
+ current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("prpr", 0)))
else:
+ if isinstance(price_resp, Exception):
+ logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {price_resp}, fallback to prpr")
current_price = Decimal(str(item.get("prpr", 0)))
except Exception as e:
- logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {e}, fallback to prpr")
+ logger.warning(f"⚠️ 현재가 파싱 실패 ({stock_code}): {e}, fallback to prpr")
current_price = Decimal(str(item.get("prpr", 0)))
# 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용
diff --git a/frontend/src/components/ChartPanel.tsx b/frontend/src/components/ChartPanel.tsx
new file mode 100644
index 0000000..380fd2f
--- /dev/null
+++ b/frontend/src/components/ChartPanel.tsx
@@ -0,0 +1,115 @@
+import React from 'react'
+import { Brain, RefreshCw } from 'lucide-react'
+import { TradingChart } from './TradingChart'
+
+const formatTime = (isoStr?: string) => {
+ if (!isoStr) return null
+ const d = new Date(isoStr)
+ return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
+}
+
+interface ChartPanelProps {
+ chartData: any[] | null | undefined
+ chartLoading: boolean
+ selectedStock: string
+ chartPeriod: 'D' | 'W' | 'M'
+ onPeriodChange: (period: 'D' | 'W' | 'M') => void
+ stockName: string
+}
+
+const ChartPanel = React.memo(({
+ chartData,
+ chartLoading,
+ selectedStock,
+ chartPeriod,
+ onPeriodChange,
+ stockName,
+}: ChartPanelProps) => {
+ return (
+
+
+
+
+ {stockName || '종목을 선택해주세요'}
+ {selectedStock && (
+
+ {selectedStock}
+
+ )}
+
+
+
+ {(['D', 'W', 'M'] as const).map((p) => (
+ onPeriodChange(p)}
+ className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${chartPeriod === p
+ ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
+ : 'text-gray-400 hover:text-white hover:bg-gray-800'
+ }`}
+ >
+ {p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
+
+ ))}
+
+
+
+
+ {chartLoading ? (
+
+ ) : chartData && chartData.length > 0 ? (
+
+ ) : (
+
+ 데이터가 없습니다.
+
+ )}
+
+
+ )
+})
+
+ChartPanel.displayName = 'ChartPanel'
+
+// AI 분석 패널 — ChartPanel과 같은 파일에 배치 (center column 분리용)
+interface AiAnalysisPanelProps {
+ aiAnalysisData: { analysis: string; data: any } | null | undefined
+}
+
+export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelProps) => {
+ const aiAnalysisText = aiAnalysisData?.analysis || null
+ return (
+
+
+
+
+ AI 투자 분석
+
+ {aiAnalysisData?.data?.analyzed_at && (
+
+ {formatTime(aiAnalysisData.data.analyzed_at)}
+
+ )}
+
+
+ {aiAnalysisText ? (
+ typeof aiAnalysisText === 'string'
+ ? aiAnalysisText
+ : JSON.stringify(aiAnalysisText, null, 2)
+ ) : (
+
+
종목을 선택하면 AI 투자 분석을 표시합니다.
+
+ )}
+
+
+ )
+})
+
+AiAnalysisPanel.displayName = 'AiAnalysisPanel'
+export default ChartPanel
diff --git a/frontend/src/components/JournalPanel.tsx b/frontend/src/components/JournalPanel.tsx
new file mode 100644
index 0000000..af37ebc
--- /dev/null
+++ b/frontend/src/components/JournalPanel.tsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import { Activity } from 'lucide-react'
+
+interface JournalPanelProps {
+ journals: any[] | undefined
+ onResetPending: () => void
+}
+
+const JournalPanel = React.memo(({ journals, onResetPending }: JournalPanelProps) => {
+ return (
+
+
+
+
+
+
+ 매매일지
+
+
+ 미체결 정리
+
+
+
+ {journals && journals.length > 0 ? (
+ journals.map((journal: any) => {
+ const journalDate = new Date(journal.journal_date || journal.created_at)
+ const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
+ const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
+ const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
+ const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
+ const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
+ const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'))
+ const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'))
+
+ return (
+
+
+
+
+ {isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
+
+ {journal.stock_name || journal.stock_code}
+ {hasProfitTag && 익절 }
+ {hasLossTag && 손절 }
+
+
{dateStr} {timeStr}
+
+
+ {journal.content}
+
+ {tags.length > 0 && (
+
+ {tags.slice(0, 4).map((tag: string, i: number) => (
+ #{tag}
+ ))}
+
+ )}
+
+ )
+ })
+ ) : (
+
+ 매매일지 없음
+ 매도 체결 후 자동으로 생성됩니다
+
+ )}
+
+
+
+
+ )
+})
+
+JournalPanel.displayName = 'JournalPanel'
+export default JournalPanel
diff --git a/frontend/src/components/OrderbookPanel.tsx b/frontend/src/components/OrderbookPanel.tsx
new file mode 100644
index 0000000..2150a06
--- /dev/null
+++ b/frontend/src/components/OrderbookPanel.tsx
@@ -0,0 +1,98 @@
+import React from 'react'
+import { Activity } from 'lucide-react'
+
+interface OrderbookPanelProps {
+ orderbook: any | null | undefined
+}
+
+const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
+ return (
+
+
+
+ 실시간 호가
+
+
+ {orderbook ? (() => {
+ const currentPrice = orderbook.current_price
+ const priceUnit =
+ currentPrice < 1000 ? 1
+ : currentPrice < 5000 ? 5
+ : currentPrice < 10000 ? 10
+ : currentPrice < 50000 ? 50
+ : currentPrice < 100000 ? 100
+ : currentPrice < 500000 ? 500
+ : 1000
+ const steps = 10
+
+ const sequentialAsks: { price: number; volume: number }[] = []
+ for (let i = steps; i >= 1; i--) {
+ const price = currentPrice + priceUnit * i
+ const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price)
+ sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 })
+ }
+
+ const sequentialBids: { price: number; volume: number }[] = []
+ for (let i = 1; i <= steps; i++) {
+ const price = currentPrice - priceUnit * i
+ const existingBid = orderbook.bids?.find((bid: any) => bid.price === price)
+ sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 })
+ }
+
+ return (
+
+
+ {sequentialAsks.map((ask, i) => (
+
+
+ {ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
+
+
+ {ask.price.toLocaleString()}
+
+
+
+ ))}
+
+
+
+
+
+ {currentPrice?.toLocaleString()}
+
+ {orderbook.current_volume > 0 && (
+
+ ({orderbook.current_volume.toLocaleString()})
+
+ )}
+
+
연속 호가창
+
+
+
+ {sequentialBids.map((bid, i) => (
+
+
+
+ {bid.price.toLocaleString()}
+
+
+ {bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
+
+
+ ))}
+
+
+ )
+ })() : (
+
+ 종목을 선택하면 호가 정보가 표시됩니다.
+
+ )}
+
+
+ )
+})
+
+OrderbookPanel.displayName = 'OrderbookPanel'
+export default OrderbookPanel
diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx
new file mode 100644
index 0000000..8a38fe7
--- /dev/null
+++ b/frontend/src/components/PortfolioPanel.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { Activity } from 'lucide-react'
+
+interface PortfolioPanelProps {
+ portfolio: any[] | undefined
+ livePrices: Record | undefined
+ selectedStock: string
+ onSelectStock: (code: string) => void
+ onRefetchAiAnalysis: () => void
+ onSell: (stockCode: string, stockName: string, quantity: number) => Promise
+}
+
+const PortfolioPanel = React.memo(({
+ portfolio,
+ livePrices,
+ selectedStock,
+ onSelectStock,
+ onRefetchAiAnalysis,
+ onSell,
+}: PortfolioPanelProps) => {
+ return (
+
+
+
+
+ 보유 종목
+
+
+ {portfolio?.length || 0}
+
+
+
+ {portfolio && portfolio.length > 0 ? (
+ portfolio.map((stock: any) => {
+ const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
+ const avgPrice = Number(stock.average_price || 0)
+ const quantity = Number(stock.quantity || 0)
+ const totalValue = currentPrice * quantity
+ const profitLossRate = avgPrice > 0
+ ? ((currentPrice - avgPrice) / avgPrice) * 100
+ : Number(stock.profit_loss_rate || 0)
+
+ const handleSellClick = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (!confirm(`${stock.stock_name} 전량(${quantity}주)을 시장가로 매도하시겠습니까?`)) return
+ try {
+ await onSell(stock.stock_code, stock.stock_name, quantity)
+ } catch (error: any) {
+ alert(`매도 실패: ${error.response?.data?.detail || error.message}`)
+ }
+ }
+
+ return (
+
+
{ onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
+ className="cursor-pointer"
+ >
+
+
+ {stock.stock_name}
+
+ 0 ? 'text-red-400' : 'text-blue-400'}`}>
+ {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
+
+
+
+ {stock.stock_code}
+ {quantity}주
+
+
+ {avgPrice.toLocaleString()}원
+ {currentPrice.toLocaleString()}원
+
+
+ 평가금액
+ 0 ? 'text-red-400' : 'text-blue-400'}`}>
+ {totalValue.toLocaleString()}원
+
+
+
+
+ 🔵 전량 청산 (시장가)
+
+
+ )
+ })
+ ) : (
+
+ )}
+
+
+ )
+})
+
+PortfolioPanel.displayName = 'PortfolioPanel'
+export default PortfolioPanel
diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx
new file mode 100644
index 0000000..d6b29f5
--- /dev/null
+++ b/frontend/src/components/RecommendationPanel.tsx
@@ -0,0 +1,133 @@
+import React from 'react'
+import { Brain, RefreshCw } from 'lucide-react'
+
+const formatTime = (isoStr?: string) => {
+ if (!isoStr) return null
+ const d = new Date(isoStr)
+ return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
+}
+
+interface RecommendationPanelProps {
+ recommendations: any | undefined
+ isError: boolean
+ onRefetch: () => void
+ selectedStock: string
+ onSelectStock: (code: string) => void
+ onRefetchAiAnalysis: () => void
+ heldStockCodes: Set
+ pendingBuyStockCodes: Set
+ livePrices: Record | undefined
+}
+
+const RecommendationPanel = React.memo(({
+ recommendations,
+ isError,
+ onRefetch,
+ selectedStock,
+ onSelectStock,
+ onRefetchAiAnalysis,
+ heldStockCodes,
+ pendingBuyStockCodes,
+ livePrices,
+}: RecommendationPanelProps) => {
+ return (
+
+
+
+
+ 분석 종목
+
+
+ {recommendations?.batch_time && (
+
+ {formatTime(recommendations.batch_time)}
+
+ )}
+
+ {recommendations?.analyzed_stocks || 0}
+
+
+
+
+
+
+
+ {isError ? (
+
+
⚠️ API 오류
+
백엔드 로그 확인 필요
+
재시도
+
+ ) : recommendations === undefined ? (
+
+
+
분석 종목 로딩 중...
+
최초 로딩 시 10초 정도 소요됩니다
+
+ ) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
+ (recommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => {
+ const isRecommended = (recommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code)
+ const isHeld = heldStockCodes.has(stock.stock_code)
+ const isPending = pendingBuyStockCodes.has(stock.stock_code)
+ const changeRate = stock.change_rate || 0
+ return (
+
{ onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
+ className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
+ >
+
+
+
+ {stock.stock_name}
+ ({stock.stock_code})
+
+ {stock.signals && stock.signals.length > 0 && (
+
+ {stock.signals.slice(0, 2).join(' · ')}
+
+ )}
+
+
+ {isPending ? (
+
+ 매수중
+
+ ) : isHeld ? (
+ 보유중
+ ) : isRecommended ? (
+ 매수신호
+ ) : null}
+ 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
+ {changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
+
+
+
+
+ {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원
+
+ {stock.rsi != null && RSI {stock.rsi.toFixed(0)} }
+ {stock.stochastic_k != null && K {stock.stochastic_k.toFixed(0)} }
+ {stock.score > 0 && {stock.score}점 }
+
+
+
+ )
+ })
+ ) : (
+
+
배치 분석 데이터가 없습니다.
+
make batch-test 실행 필요
+
+ )}
+
+
+ )
+})
+
+RecommendationPanel.displayName = 'RecommendationPanel'
+export default RecommendationPanel
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index de6d09a..a522dbd 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,18 +1,16 @@
-import { useEffect, useState, useRef, useMemo } from 'react'
+import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
import { PortfolioSummary } from '../components/PortfolioSummary'
-import { TradingChart } from '../components/TradingChart'
-import { Activity, Brain, RefreshCw } from 'lucide-react'
+import PortfolioPanel from '../components/PortfolioPanel'
+import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
+import OrderbookPanel from '../components/OrderbookPanel'
+import RecommendationPanel from '../components/RecommendationPanel'
+import JournalPanel from '../components/JournalPanel'
+import { Activity } from 'lucide-react'
import './Dashboard.css'
-const formatTime = (isoStr?: string) => {
- if (!isoStr) return null;
- const d = new Date(isoStr);
- return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
-};
-
// 마켓 운영 시간 체크 (한국장 + 미국장)
const checkMarketOpen = () => {
const now = new Date();
@@ -45,10 +43,11 @@ const Dashboard = () => {
const isMarketOpen = checkMarketOpen();
// ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
- const handleSelectStock = (code: string) => {
+ // useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
+ const handleSelectStock = useCallback((code: string) => {
setSelectedStock(code)
localStorage.setItem('selectedStock', code)
- }
+ }, [])
// --- Queries ---
@@ -220,15 +219,40 @@ const Dashboard = () => {
staleTime: 5000,
})
- const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code))
-
- const pendingBuyStockCodes = new Set(
- (tradeHistory || [])
- .filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
- .map((t: any) => t.stock_code)
+ // useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
+ const heldStockCodes = useMemo(
+ () => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
+ [portfolio]
)
- const resetPendingTrades = async () => {
+ const pendingBuyStockCodes = useMemo(
+ () => new Set(
+ (tradeHistory || [])
+ .filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
+ .map((t: any) => t.stock_code as string)
+ ),
+ [tradeHistory]
+ )
+
+ // 차트 헤더용 종목명 (useMemo로 안정화)
+ const stockName = useMemo(() => {
+ return (
+ portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name ||
+ ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name ||
+ selectedStock ||
+ ''
+ )
+ }, [portfolio, ruleBasedRecommendations, selectedStock])
+
+ // useCallback으로 안정화 → 패널 리렌더 방지
+ const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
+ await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
+ queryClient.invalidateQueries({ queryKey: ['portfolio'] })
+ queryClient.invalidateQueries({ queryKey: ['trade-history'] })
+ console.log(`✅ ${stockName} 매도 주문 완료`)
+ }, [queryClient])
+
+ const resetPendingTrades = useCallback(async () => {
try {
const response = await api.delete('/trades/pending');
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
@@ -236,7 +260,7 @@ const Dashboard = () => {
} catch (error: any) {
console.error('미체결 주문 정리 실패:', error);
}
- };
+ }, [queryClient])
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
// ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지
@@ -323,7 +347,14 @@ const Dashboard = () => {
staleTime: 30000,
});
- const aiAnalysisText = aiAnalysisData?.analysis || null;
+ // useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지
+ const stableRefetchAiAnalysis = useCallback(() => {
+ refetchAiAnalysis()
+ }, [refetchAiAnalysis])
+
+ const stableRefetchRecommendations = useCallback(() => {
+ refetchRecommendations()
+ }, [refetchRecommendations])
// 초기 종목 선택 (localStorage에 없을 때만 동작)
useEffect(() => {
@@ -337,7 +368,7 @@ const Dashboard = () => {
}
}
}
- }, [portfolio, ruleBasedRecommendations, selectedStock])
+ }, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
@@ -375,428 +406,54 @@ const Dashboard = () => {
{/* [Left Column] 보유 종목 */}
-
-
-
-
- 보유 종목
-
-
- {portfolio?.length || 0}
-
-
-
- {portfolio && portfolio.length > 0 ? (
- portfolio.map((stock: any) => {
- const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0);
- const avgPrice = Number(stock.average_price || 0);
- const quantity = Number(stock.quantity || 0);
- const totalValue = currentPrice * quantity;
- const profitLossRate = avgPrice > 0
- ? ((currentPrice - avgPrice) / avgPrice) * 100
- : Number(stock.profit_loss_rate || 0);
-
- const handleSell = async (e: React.MouseEvent) => {
- e.stopPropagation();
- if (!confirm(`${stock.stock_name} 전량(${quantity}주)을 시장가로 매도하시겠습니까?`)) {
- return;
- }
- try {
- await api.post('/portfolio/sell', {
- stock_code: stock.stock_code,
- quantity: quantity,
- price: null
- });
- queryClient.invalidateQueries({ queryKey: ['portfolio'] });
- queryClient.invalidateQueries({ queryKey: ['trade-history'] });
- console.log(`✅ ${stock.stock_name} 매도 주문 완료`);
- } catch (error: any) {
- console.error('매도 실패:', error);
- alert(`매도 실패: ${error.response?.data?.detail || error.message}`);
- }
- };
-
- return (
-
-
{
- handleSelectStock(stock.stock_code);
- refetchAiAnalysis();
- }}
- className="cursor-pointer"
- >
-
-
- {stock.stock_name}
-
- 0 ? 'text-red-400' : 'text-blue-400'}`}>
- {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
-
-
-
- {stock.stock_code}
- {quantity}주
-
-
- {avgPrice.toLocaleString()}원
- {currentPrice.toLocaleString()}원
-
-
- 평가금액
- 0 ? 'text-red-400' : 'text-blue-400'}`}>
- {totalValue.toLocaleString()}원
-
-
-
-
- 🔵 전량 청산 (시장가)
-
-
- );
- })
- ) : (
-
- )}
-
-
+
{/* [Center Column] 차트 & 트레이딩 정보 */}
{/* 차트 섹션 */}
-
-
-
-
- {portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name ||
- ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name ||
- selectedStock || '종목을 선택해주세요'}
- {selectedStock && {selectedStock} }
-
-
-
- {['D', 'W', 'M'].map((p) => (
- setChartPeriod(p as any)}
- className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${chartPeriod === p ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20' : 'text-gray-400 hover:text-white hover:bg-gray-800'
- }`}
- >
- {p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
-
- ))}
-
-
+
-
- {chartLoading ? (
-
- ) : chartData && chartData.length > 0 ? (
-
- ) : (
-
- 데이터가 없습니다.
-
- )}
-
-
-
- {/* 하단: AI 투자 분석 & 호가창 */}
+ {/* 하단: AI 투자 분석 & 호가창
+ - AiAnalysisPanel: selectedStock 변경 시에만 리렌더
+ - OrderbookPanel: 5초마다 독립 리렌더 (다른 패널 영향 없음) */}
- {/* AI 투자 분석 */}
-
-
-
-
- AI 투자 분석
-
- {aiAnalysisData?.data?.analyzed_at && (
-
- {formatTime(aiAnalysisData.data.analyzed_at)}
-
- )}
-
-
- {aiAnalysisText ? (
- typeof aiAnalysisText === 'string'
- ? aiAnalysisText
- : JSON.stringify(aiAnalysisText, null, 2)
- ) : (
-
-
종목을 선택하면 AI 투자 분석을 표시합니다.
-
- )}
-
-
-
- {/* 호가창 */}
-
-
-
- 실시간 호가
-
-
- {orderbook ? (() => {
- const currentPrice = orderbook.current_price;
- const priceUnit =
- currentPrice < 1000 ? 1
- : currentPrice < 5000 ? 5
- : currentPrice < 10000 ? 10
- : currentPrice < 50000 ? 50
- : currentPrice < 100000 ? 100
- : currentPrice < 500000 ? 500
- : 1000;
- const steps = 10;
-
- const sequentialAsks = [];
- for (let i = steps; i >= 1; i--) {
- const price = currentPrice + (priceUnit * i);
- const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price);
- sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 });
- }
-
- const sequentialBids = [];
- for (let i = 1; i <= steps; i++) {
- const price = currentPrice - (priceUnit * i);
- const existingBid = orderbook.bids?.find((bid: any) => bid.price === price);
- sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 });
- }
-
- return (
-
-
- {sequentialAsks.map((ask: any, i: number) => (
-
-
- {ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
-
-
- {ask.price.toLocaleString()}
-
-
-
- ))}
-
-
-
-
-
- {currentPrice?.toLocaleString()}
-
- {orderbook.current_volume > 0 && (
-
- ({orderbook.current_volume.toLocaleString()})
-
- )}
-
-
연속 호가창
-
-
-
- {sequentialBids.map((bid: any, i: number) => (
-
-
-
- {bid.price.toLocaleString()}
-
-
- {bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
-
-
- ))}
-
-
- );
- })() : (
-
- 종목을 선택하면 호가 정보가 표시됩니다.
-
- )}
-
-
+
+
{/* [Right Column] 분석 종목 & 매매일지 */}
- {/* 분석 종목 */}
-
-
-
-
- 분석 종목
-
-
- {ruleBasedRecommendations?.batch_time && (
-
- {formatTime(ruleBasedRecommendations.batch_time)}
-
- )}
-
- {ruleBasedRecommendations?.analyzed_stocks || 0}
-
- refetchRecommendations()}
- className="p-1 hover:bg-gray-700/50 rounded transition-colors"
- title="분석 종목 새로고침"
- >
-
-
-
-
-
- {isRecommendationsError ? (
-
-
⚠️ API 오류
-
백엔드 로그 확인 필요
-
refetchRecommendations()} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도
-
- ) : ruleBasedRecommendations === undefined ? (
-
-
-
분석 종목 로딩 중...
-
최초 로딩 시 10초 정도 소요됩니다
-
- ) : (ruleBasedRecommendations?.analyzed_stock_list || []).length > 0 ? (
- (ruleBasedRecommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => {
- const isRecommended = (ruleBasedRecommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code)
- const isHeld = heldStockCodes.has(stock.stock_code)
- const isPending = pendingBuyStockCodes.has(stock.stock_code)
- const changeRate = stock.change_rate || 0
- return (
-
{
- handleSelectStock(stock.stock_code);
- refetchAiAnalysis();
- }}
- className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
- >
-
-
-
- {stock.stock_name}
- ({stock.stock_code})
-
- {stock.signals && stock.signals.length > 0 && (
-
- {stock.signals.slice(0, 2).join(' · ')}
-
- )}
-
-
- {isPending ? (
-
- 매수중
-
- ) : isHeld ? (
- 보유중
- ) : isRecommended ? (
- 매수신호
- ) : null}
- 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
- {changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
-
-
-
-
- {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원
-
- {stock.rsi != null && RSI {stock.rsi.toFixed(0)} }
- {stock.stochastic_k != null && K {stock.stochastic_k.toFixed(0)} }
- {stock.score > 0 && {stock.score}점 }
-
-
-
- )
- })
- ) : (
-
-
배치 분석 데이터가 없습니다.
-
make batch-test 실행 필요
-
- )}
-
-
-
- {/* 매매일지 */}
-
-
-
-
-
-
- 매매일지
-
-
- 미체결 정리
-
-
-
- {journals && journals.length > 0 ? (
- journals.map((journal: any) => {
- const journalDate = new Date(journal.journal_date || journal.created_at);
- const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' });
- const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
- const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive';
- const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative';
- const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [];
- const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'));
- const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'));
-
- return (
-
-
-
-
- {isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
-
- {journal.stock_name || journal.stock_code}
- {hasProfitTag && 익절 }
- {hasLossTag && 손절 }
-
-
{dateStr} {timeStr}
-
-
- {journal.content}
-
- {tags.length > 0 && (
-
- {tags.slice(0, 4).map((tag: string, i: number) => (
- #{tag}
- ))}
-
- )}
-
- );
- })
- ) : (
-
- 매매일지 없음
- 매도 체결 후 자동으로 생성됩니다
-
- )}
-
-
-
-
+
+