대시보드 분리
This commit is contained in:
115
frontend/src/components/ChartPanel.tsx
Normal file
115
frontend/src/components/ChartPanel.tsx
Normal 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
|
||||
79
frontend/src/components/JournalPanel.tsx
Normal file
79
frontend/src/components/JournalPanel.tsx
Normal 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
|
||||
98
frontend/src/components/OrderbookPanel.tsx
Normal file
98
frontend/src/components/OrderbookPanel.tsx
Normal 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
|
||||
105
frontend/src/components/PortfolioPanel.tsx
Normal file
105
frontend/src/components/PortfolioPanel.tsx
Normal 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
|
||||
133
frontend/src/components/RecommendationPanel.tsx
Normal file
133
frontend/src/components/RecommendationPanel.tsx
Normal 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
|
||||
@@ -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 (
|
||||
<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] 보유 종목 */}
|
||||
<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">
|
||||
<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 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>
|
||||
<PortfolioPanel
|
||||
portfolio={portfolio}
|
||||
livePrices={livePrices}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={handleSelectStock}
|
||||
onRefetchAiAnalysis={stableRefetchAiAnalysis}
|
||||
onSell={handleSell}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* [Center Column] 차트 & 트레이딩 정보 */}
|
||||
<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">
|
||||
<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">
|
||||
{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 && <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>
|
||||
<ChartPanel
|
||||
chartData={chartData}
|
||||
chartLoading={chartLoading}
|
||||
selectedStock={selectedStock}
|
||||
chartPeriod={chartPeriod}
|
||||
onPeriodChange={setChartPeriod}
|
||||
stockName={stockName}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 하단: AI 투자 분석 & 호가창 */}
|
||||
{/* 하단: AI 투자 분석 & 호가창
|
||||
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
|
||||
- OrderbookPanel: 5초마다 독립 리렌더 (다른 패널 영향 없음) */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
|
||||
{/* AI 투자 분석 */}
|
||||
<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>
|
||||
|
||||
{/* 호가창 */}
|
||||
<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>
|
||||
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
|
||||
<OrderbookPanel orderbook={orderbook} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* [Right Column] 분석 종목 & 매매일지 */}
|
||||
<div className="col-span-3 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-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">
|
||||
{ruleBasedRecommendations?.batch_time && (
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
{formatTime(ruleBasedRecommendations.batch_time)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
|
||||
{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>
|
||||
<RecommendationPanel
|
||||
recommendations={ruleBasedRecommendations}
|
||||
isError={isRecommendationsError}
|
||||
onRefetch={stableRefetchRecommendations}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={handleSelectStock}
|
||||
onRefetchAiAnalysis={stableRefetchAiAnalysis}
|
||||
heldStockCodes={heldStockCodes}
|
||||
pendingBuyStockCodes={pendingBuyStockCodes}
|
||||
livePrices={livePrices}
|
||||
/>
|
||||
<JournalPanel
|
||||
journals={journals}
|
||||
onResetPending={resetPendingTrades}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user