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) => ( + + ))} +
+
+ +
+ {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 ( + + ) + }) + ) : ( +
+

배치 분석 데이터가 없습니다.

+

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) => ( - - ))} -
-
+ -
- {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} - - -
-
-
- {isRecommendationsError ? ( -
-

⚠️ API 오류

-

백엔드 로그 확인 필요

- -
- ) : 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 ( - - ) - }) - ) : ( -
-

배치 분석 데이터가 없습니다.

-

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} - ))} -
- )} -
- ); - }) - ) : ( -
- 매매일지 없음 - 매도 체결 후 자동으로 생성됩니다 -
- )} -
-
-
-
+ +