대시보드 분리

This commit is contained in:
2026-02-24 16:39:04 +09:00
parent 3e19862c02
commit 1c07a59352
8 changed files with 654 additions and 449 deletions

View File

@@ -8,6 +8,7 @@
""" """
# 필요한 라이브러리 및 모듈 임포트 # 필요한 라이브러리 및 모듈 임포트
from datetime import datetime, timedelta from datetime import datetime, timedelta
import asyncio
import random import random
import logging import logging
import pytz 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()] code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
prices: Dict[str, float] = {} prices: Dict[str, float] = {}
# 각 종목 코드에 대해 순차적으로 현재가 조회 # 병렬로 현재가 일괄 조회 (asyncio.gather)
# 클래스 내부에 API 호출 간격을 조절하는 쓰로틀링 기능이 내장되어 있음 async def _fetch_one(code: str):
for code in code_list:
try: try:
data = await korea_investment_api.get_current_price(code) data = await korea_investment_api.get_current_price(code)
if data and data.get("output"): if data and data.get("output"):
o = data["output"] o = data["output"]
# 콤마가 포함된 가격 문자열을 float으로 변환 return code, float(str(o.get("stck_prpr", "0")).replace(",", ""))
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
prices[code] = current_price
except Exception as e: except Exception as e:
# 특정 종목 조회 실패 시 경고 로그 기록
logger.warning(f"Failed to fetch price for {code}: {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 { return {

View File

@@ -76,7 +76,7 @@ class PortfolioCache:
return False return False
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관) # 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
return True 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 True
return False return False
@@ -331,9 +331,25 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
processed_codes = set() processed_codes = set()
new_holdings = [] new_holdings = []
if "output1" in balance_data: 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"]: for item in balance_data["output1"]:
stock_code = item.get("pdno") stock_code = item.get("pdno")
# 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리) # 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리)
# 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음. # 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음.
if stock_code in 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: if quantity <= 0:
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity") logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue continue
# KIS API 필드명 정확히 매핑 (로깅 추가) # KIS API 필드명 정확히 매핑 (로깅 추가)
average_price = Decimal(str(item.get("pchs_avg_pric", 0))) average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# 🔥 실시간 현재가 조회 (잔고 조회의 prpr은 실시간이 아님) # 실시간 현재가 (병렬 조회 결과 참조)
price_resp = _realtime_price_map.get(stock_code)
try: try:
price_resp = await korea_investment_api.get_current_price(stock_code) if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp:
if price_resp and "output" in price_resp:
realtime_price = int(price_resp["output"].get("stck_prpr", 0)) realtime_price = int(price_resp["output"].get("stck_prpr", 0))
if realtime_price > 0: current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("prpr", 0)))
current_price = Decimal(str(realtime_price))
else:
current_price = Decimal(str(item.get("prpr", 0)))
else: else:
if isinstance(price_resp, Exception):
logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {price_resp}, fallback to prpr")
current_price = Decimal(str(item.get("prpr", 0))) current_price = Decimal(str(item.get("prpr", 0)))
except Exception as e: 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))) current_price = Decimal(str(item.get("prpr", 0)))
# 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용 # 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용

View File

@@ -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 (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0">
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
<div>
<h2 className="text-lg font-bold flex items-center gap-3 text-white">
{stockName || '종목을 선택해주세요'}
{selectedStock && (
<span className="text-xs font-normal text-gray-500 font-mono bg-gray-800 px-2 py-0.5 rounded">
{selectedStock}
</span>
)}
</h2>
</div>
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700/50 scale-90">
{(['D', 'W', 'M'] as const).map((p) => (
<button
key={p}
onClick={() => 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' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
<div className="flex-1 relative bg-[#0b0e14] rounded-b-lg overflow-hidden">
{chartLoading ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-[#0b0e14]">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="animate-spin w-6 h-6 text-blue-500" />
<span className="text-sm">차트 로딩중...</span>
</div>
</div>
) : chartData && chartData.length > 0 ? (
<TradingChart data={chartData} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
데이터가 없습니다.
</div>
)}
</div>
</div>
)
})
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 (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<div className="flex justify-between items-center mb-2">
<h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
<Brain size={16} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
{aiAnalysisText ? (
typeof aiAnalysisText === 'string'
? aiAnalysisText
: JSON.stringify(aiAnalysisText, null, 2)
) : (
<div className="h-full flex items-center justify-center text-gray-600 text-center text-xs">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}
</div>
</div>
)
})
AiAnalysisPanel.displayName = 'AiAnalysisPanel'
export default ChartPanel

View File

@@ -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 (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
<Activity size={16} />
매매일지
</h3>
<button
onClick={onResetPending}
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
{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 (
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
</div>
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
</div>
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
))}
</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
<span>매매일지 없음</span>
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
</div>
)}
</div>
</div>
</div>
</div>
)
})
JournalPanel.displayName = 'JournalPanel'
export default JournalPanel

View File

@@ -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 (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
<Activity size={16} />
실시간 호가
</h3>
<div className="overflow-y-auto custom-scrollbar flex-1">
{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 (
<div className="space-y-2">
<div className="space-y-0.5">
{sequentialAsks.map((ask, i) => (
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div className="text-xs text-gray-300 font-medium text-right font-mono">
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
</div>
<div className="text-sm font-bold text-red-400 font-mono text-center">
{ask.price.toLocaleString()}
</div>
<div></div>
</div>
))}
</div>
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
<div className="flex items-center justify-center gap-4 px-4">
<div className="text-2xl font-bold text-red-400 font-mono">
{currentPrice?.toLocaleString()}
</div>
{orderbook.current_volume > 0 && (
<div className="text-xs text-gray-400 font-mono">
({orderbook.current_volume.toLocaleString()})
</div>
)}
</div>
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
</div>
<div className="space-y-0.5">
{sequentialBids.map((bid, i) => (
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div></div>
<div className="text-sm font-bold text-blue-400 font-mono text-center">
{bid.price.toLocaleString()}
</div>
<div className="text-xs text-gray-300 font-medium text-left font-mono">
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
</div>
</div>
))}
</div>
</div>
)
})() : (
<div className="h-full flex items-center justify-center text-gray-600 text-xs text-center">
종목을 선택하면 호가 정보가 표시됩니다.
</div>
)}
</div>
</div>
)
})
OrderbookPanel.displayName = 'OrderbookPanel'
export default OrderbookPanel

View File

@@ -0,0 +1,105 @@
import React from 'react'
import { Activity } from 'lucide-react'
interface PortfolioPanelProps {
portfolio: any[] | undefined
livePrices: Record<string, number> | undefined
selectedStock: string
onSelectStock: (code: string) => void
onRefetchAiAnalysis: () => void
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
}
const PortfolioPanel = React.memo(({
portfolio,
livePrices,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
onSell,
}: PortfolioPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Activity className="w-4 h-4 text-blue-400" />
보유 종목
</h3>
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">
{portfolio?.length || 0}
</span>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{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 (
<div
key={stock.stock_code}
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
>
<div
onClick={() => { onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
className="cursor-pointer"
>
<div className="flex justify-between items-start mb-1">
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
{stock.stock_name}
</span>
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
<span className="font-mono">{stock.stock_code}</span>
<span>{quantity}주</span>
</div>
<div className="flex justify-between items-center text-[11px]">
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
</div>
<div className="flex justify-between items-center text-[11px] mt-0.5">
<span className="text-gray-500">평가금액</span>
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{totalValue.toLocaleString()}원
</span>
</div>
</div>
<button
onClick={handleSellClick}
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
>
🔵 전량 청산 (시장가)
</button>
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>보유 중인 종목이 없습니다.</p>
</div>
)}
</div>
</div>
)
})
PortfolioPanel.displayName = 'PortfolioPanel'
export default PortfolioPanel

View File

@@ -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<string>
pendingBuyStockCodes: Set<string>
livePrices: Record<string, number> | undefined
}
const RecommendationPanel = React.memo(({
recommendations,
isError,
onRefetch,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
heldStockCodes,
pendingBuyStockCodes,
livePrices,
}: RecommendationPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Brain className="w-4 h-4 text-purple-400" />
분석 종목
</h3>
<div className="flex items-center gap-2">
{recommendations?.batch_time && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(recommendations.batch_time)}
</span>
)}
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
{recommendations?.analyzed_stocks || 0}
</span>
<button
onClick={onRefetch}
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
title="분석 종목 새로고침"
>
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
</button>
</div>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{isError ? (
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
<p>⚠️ API 오류</p>
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
</div>
) : recommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
<p>분석 종목 로딩 중...</p>
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (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 (
<button
key={`${stock.stock_code}-${idx}`}
onClick={() => { 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'}`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
</div>
{stock.signals && stock.signals.length > 0 && (
<div className="text-[9px] text-gray-500 truncate">
{stock.signals.slice(0, 2).join(' · ')}
</div>
)}
</div>
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
{isPending ? (
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
) : isRecommended ? (
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
) : null}
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
</div>
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
<span className="flex gap-2">
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
{stock.score > 0 && <span className="text-purple-400">{stock.score}점</span>}
</span>
</div>
</button>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>배치 분석 데이터가 없습니다.</p>
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>
</div>
)
})
RecommendationPanel.displayName = 'RecommendationPanel'
export default RecommendationPanel

View File

@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api' import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket' import { useWebSocket } from '../hooks/useWebSocket'
import { PortfolioSummary } from '../components/PortfolioSummary' import { PortfolioSummary } from '../components/PortfolioSummary'
import { TradingChart } from '../components/TradingChart' import PortfolioPanel from '../components/PortfolioPanel'
import { Activity, Brain, RefreshCw } from 'lucide-react' 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' 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 checkMarketOpen = () => {
const now = new Date(); const now = new Date();
@@ -45,10 +43,11 @@ const Dashboard = () => {
const isMarketOpen = checkMarketOpen(); const isMarketOpen = checkMarketOpen();
// ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거) // ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
const handleSelectStock = (code: string) => { // useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
const handleSelectStock = useCallback((code: string) => {
setSelectedStock(code) setSelectedStock(code)
localStorage.setItem('selectedStock', code) localStorage.setItem('selectedStock', code)
} }, [])
// --- Queries --- // --- Queries ---
@@ -220,15 +219,40 @@ const Dashboard = () => {
staleTime: 5000, staleTime: 5000,
}) })
const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code)) // useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
const heldStockCodes = useMemo(
const pendingBuyStockCodes = new Set( () => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
(tradeHistory || []) [portfolio]
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
.map((t: any) => t.stock_code)
) )
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 { try {
const response = await api.delete('/trades/pending'); const response = await api.delete('/trades/pending');
queryClient.invalidateQueries({ queryKey: ['trade-history'] }); queryClient.invalidateQueries({ queryKey: ['trade-history'] });
@@ -236,7 +260,7 @@ const Dashboard = () => {
} catch (error: any) { } catch (error: any) {
console.error('미체결 주문 정리 실패:', error); console.error('미체결 주문 정리 실패:', error);
} }
}; }, [queryClient])
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
// ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지 // ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지
@@ -323,7 +347,14 @@ const Dashboard = () => {
staleTime: 30000, staleTime: 30000,
}); });
const aiAnalysisText = aiAnalysisData?.analysis || null; // useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지
const stableRefetchAiAnalysis = useCallback(() => {
refetchAiAnalysis()
}, [refetchAiAnalysis])
const stableRefetchRecommendations = useCallback(() => {
refetchRecommendations()
}, [refetchRecommendations])
// 초기 종목 선택 (localStorage에 없을 때만 동작) // 초기 종목 선택 (localStorage에 없을 때만 동작)
useEffect(() => { useEffect(() => {
@@ -337,7 +368,7 @@ const Dashboard = () => {
} }
} }
} }
}, [portfolio, ruleBasedRecommendations, selectedStock]) }, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return ( return (
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col h-screen overflow-hidden"> <div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col h-screen overflow-hidden">
@@ -375,428 +406,54 @@ const Dashboard = () => {
{/* [Left Column] 보유 종목 */} {/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden"> <div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden">
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg"> <PortfolioPanel
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]"> portfolio={portfolio}
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm"> livePrices={livePrices}
<Activity className="w-4 h-4 text-blue-400" /> selectedStock={selectedStock}
보유 종목 onSelectStock={handleSelectStock}
</h3> onRefetchAiAnalysis={stableRefetchAiAnalysis}
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full"> onSell={handleSell}
{portfolio?.length || 0} />
</span>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{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 (
<div
key={stock.stock_code}
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
>
<div
onClick={() => {
handleSelectStock(stock.stock_code);
refetchAiAnalysis();
}}
className="cursor-pointer"
>
<div className="flex justify-between items-start mb-1">
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
{stock.stock_name}
</span>
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
<span className="font-mono">{stock.stock_code}</span>
<span>{quantity}주</span>
</div>
<div className="flex justify-between items-center text-[11px]">
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
</div>
<div className="flex justify-between items-center text-[11px] mt-0.5">
<span className="text-gray-500">평가금액</span>
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{totalValue.toLocaleString()}원
</span>
</div>
</div>
<button
onClick={handleSell}
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
>
🔵 전량 청산 (시장가)
</button>
</div>
);
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>보유 중인 종목이 없습니다.</p>
</div>
)}
</div>
</div>
</div> </div>
{/* [Center Column] 차트 & 트레이딩 정보 */} {/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden"> <div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
{/* 차트 섹션 */} {/* 차트 섹션 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0"> <ChartPanel
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30"> chartData={chartData}
<div> chartLoading={chartLoading}
<h2 className="text-lg font-bold flex items-center gap-3 text-white"> selectedStock={selectedStock}
{portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name || chartPeriod={chartPeriod}
ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name || onPeriodChange={setChartPeriod}
selectedStock || '종목을 선택해주세요'} stockName={stockName}
{selectedStock && <span className="text-xs font-normal text-gray-500 font-mono bg-gray-800 px-2 py-0.5 rounded">{selectedStock}</span>} />
</h2>
</div>
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700/50 scale-90">
{['D', 'W', 'M'].map((p) => (
<button
key={p}
onClick={() => 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' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
<div className="flex-1 relative bg-[#0b0e14] rounded-b-lg overflow-hidden"> {/* 하단: AI 투자 분석 & 호가창
{chartLoading ? ( - AiAnalysisPanel: selectedStock 변경 시에만 리렌더
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-[#0b0e14]"> - OrderbookPanel: 5초마다 독립 리렌더 (다른 패널 영향 없음) */}
<div className="flex flex-col items-center gap-2">
<RefreshCw className="animate-spin w-6 h-6 text-blue-500" />
<span className="text-sm">차트 로딩중...</span>
</div>
</div>
) : chartData && chartData.length > 0 ? (
<TradingChart data={chartData} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
데이터가 없습니다.
</div>
)}
</div>
</div>
{/* 하단: AI 투자 분석 & 호가창 */}
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0"> <div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
{/* AI 투자 분석 */} <AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col"> <OrderbookPanel orderbook={orderbook} />
<div className="flex justify-between items-center mb-2">
<h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
<Brain size={16} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
{aiAnalysisText ? (
typeof aiAnalysisText === 'string'
? aiAnalysisText
: JSON.stringify(aiAnalysisText, null, 2)
) : (
<div className="h-full flex items-center justify-center text-gray-600 text-center text-xs">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}
</div>
</div>
{/* 호가창 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
<Activity size={16} />
실시간 호가
</h3>
<div className="overflow-y-auto custom-scrollbar flex-1">
{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 (
<div className="space-y-2">
<div className="space-y-0.5">
{sequentialAsks.map((ask: any, i: number) => (
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div className="text-xs text-gray-300 font-medium text-right font-mono">
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
</div>
<div className="text-sm font-bold text-red-400 font-mono text-center">
{ask.price.toLocaleString()}
</div>
<div></div>
</div>
))}
</div>
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
<div className="flex items-center justify-center gap-4 px-4">
<div className="text-2xl font-bold text-red-400 font-mono">
{currentPrice?.toLocaleString()}
</div>
{orderbook.current_volume > 0 && (
<div className="text-xs text-gray-400 font-mono">
({orderbook.current_volume.toLocaleString()})
</div>
)}
</div>
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
</div>
<div className="space-y-0.5">
{sequentialBids.map((bid: any, i: number) => (
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div></div>
<div className="text-sm font-bold text-blue-400 font-mono text-center">
{bid.price.toLocaleString()}
</div>
<div className="text-xs text-gray-300 font-medium text-left font-mono">
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
</div>
</div>
))}
</div>
</div>
);
})() : (
<div className="h-full flex items-center justify-center text-gray-600 text-xs text-center">
종목을 선택하면 호가 정보가 표시됩니다.
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
{/* [Right Column] 분석 종목 & 매매일지 */} {/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden"> <div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
{/* 분석 종목 */} <RecommendationPanel
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg"> recommendations={ruleBasedRecommendations}
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]"> isError={isRecommendationsError}
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm"> onRefetch={stableRefetchRecommendations}
<Brain className="w-4 h-4 text-purple-400" /> selectedStock={selectedStock}
분석 종목 onSelectStock={handleSelectStock}
</h3> onRefetchAiAnalysis={stableRefetchAiAnalysis}
<div className="flex items-center gap-2"> heldStockCodes={heldStockCodes}
{ruleBasedRecommendations?.batch_time && ( pendingBuyStockCodes={pendingBuyStockCodes}
<span className="text-[10px] text-gray-500 font-mono"> livePrices={livePrices}
{formatTime(ruleBasedRecommendations.batch_time)} />
</span> <JournalPanel
)} journals={journals}
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full"> onResetPending={resetPendingTrades}
{ruleBasedRecommendations?.analyzed_stocks || 0} />
</span>
<button
onClick={() => refetchRecommendations()}
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
title="분석 종목 새로고침"
>
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
</button>
</div>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{isRecommendationsError ? (
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
<p>⚠️ API 오류</p>
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
<button onClick={() => refetchRecommendations()} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
</div>
) : ruleBasedRecommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
<p>분석 종목 로딩 중...</p>
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (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 (
<button
key={`${stock.stock_code}-${idx}`}
onClick={() => {
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'}`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
</div>
{stock.signals && stock.signals.length > 0 && (
<div className="text-[9px] text-gray-500 truncate">
{stock.signals.slice(0, 2).join(' · ')}
</div>
)}
</div>
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
{isPending ? (
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
) : isRecommended ? (
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
) : null}
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
</div>
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
<span className="flex gap-2">
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
{stock.score > 0 && <span className="text-purple-400">{stock.score}점</span>}
</span>
</div>
</button>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>배치 분석 데이터가 없습니다.</p>
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>
</div>
{/* 매매일지 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
<Activity size={16} />
매매일지
</h3>
<button
onClick={resetPendingTrades}
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
{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 (
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
</div>
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
</div>
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
))}
</div>
)}
</div>
);
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
<span>매매일지 없음</span>
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
</div>
)}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>