refactor: 프론트엔드 TypeScript 타입 안전성 및 커스텀 훅 분리
- types/trading.ts: 중앙 타입 정의 (CandleData, Position, Trade 등) - useMarketData.ts: 실시간 시세 처리 + 500ms 쓰로틀링 - usePortfolioData.ts: React Query 기반 포트폴리오 데이터 훅 - useTradeActions.ts: 매매 실행 액션 훅 - useWebSocket.ts: pong timeout 10초 감지 (좀비 연결 방지) - TradingChart.tsx: @ts-nocheck 제거, CandleData 타입 적용 - PortfolioPanel/RecommendationPanel: any → 구체적 타입 교체 - Dashboard.tsx: 상태 관리 로직을 커스텀 훅으로 이전 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,16 @@ 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 { usePortfolioData } from '../hooks/usePortfolioData'
|
||||
import { useMarketData } from '../hooks/useMarketData'
|
||||
import { useTradeActions } from '../hooks/useTradeActions'
|
||||
import { PortfolioSummary } from '../components/PortfolioSummary'
|
||||
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 { CandleData, TradeHistory } from '../types/trading'
|
||||
import './Dashboard.css'
|
||||
|
||||
// 마켓 운영 시간 체크 (한국장 + 미국장)
|
||||
@@ -29,47 +33,13 @@ const checkMarketOpen = () => {
|
||||
};
|
||||
|
||||
const Dashboard = () => {
|
||||
// 실시간 현재가 (KIS WebSocket tick → 즉시 반영)
|
||||
const [livePrices, setLivePrices] = useState<Record<string, number>>({})
|
||||
// 실시간 호가 (KIS WebSocket quote H0STASP0 → 선택 종목만 수신)
|
||||
const [liveQuote, setLiveQuote] = useState<any>(null)
|
||||
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
|
||||
const [chartData, setChartData] = useState<any[] | null>(null)
|
||||
const [chartData, setChartData] = useState<CandleData[] | null>(null)
|
||||
const [chartLoading, setChartLoading] = useState(false)
|
||||
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
||||
const selectedStockRef = useRef<string>('')
|
||||
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
|
||||
|
||||
const handleMarketUpdate = useCallback((data: unknown) => {
|
||||
const d = data as any
|
||||
if (d?._type === 'tick' && d.stock_code && d.price != null) {
|
||||
setLivePrices(prev => ({ ...prev, [d.stock_code]: d.price }))
|
||||
} else if (d?._type === 'quote' && d.stock_code === selectedStockRef.current) {
|
||||
setLiveQuote(d)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChartData = useCallback((msg: unknown) => {
|
||||
const m = msg as any
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
if (m?.stock_code !== selectedStockRef.current || m?.period !== chartPeriodRef.current) return
|
||||
const data = (m.data || []).map((item: any) => ({
|
||||
date: item.date,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
})).reverse()
|
||||
setChartData(data)
|
||||
setChartLoading(false)
|
||||
}, [])
|
||||
|
||||
const { connected: wsConnected, messages: wsMessages, sendMessage } = useWebSocket('dashboard', {
|
||||
onMarketUpdate: handleMarketUpdate,
|
||||
onChartData: handleChartData,
|
||||
})
|
||||
|
||||
// localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
|
||||
const [selectedStock, setSelectedStock] = useState<string>(
|
||||
() => localStorage.getItem('selectedStock') || ''
|
||||
@@ -80,7 +50,7 @@ const Dashboard = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const prevLatestTradeIdRef = useRef<number | null>(null)
|
||||
|
||||
const isMarketOpen = checkMarketOpen();
|
||||
const isMarketOpen = checkMarketOpen()
|
||||
|
||||
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
|
||||
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
|
||||
@@ -90,25 +60,94 @@ const Dashboard = () => {
|
||||
}, [])
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Queries
|
||||
// 원칙: 매매가 일어날 때만 호출되어야 하는 것은 polling 제거 + staleTime: Infinity
|
||||
// 실시간성이 필요한 것만 refetchInterval 유지
|
||||
// Custom Hooks
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ✅ 보유종목 목록 + 요약 자산 - 단일 API 호출 (lock 경쟁 제거)
|
||||
const { data: portfolioCombined, error: portfolioSummaryError } = useQuery({
|
||||
queryKey: ['portfolio-combined'],
|
||||
// Portfolio data hook
|
||||
const {
|
||||
portfolio,
|
||||
portfolioSummary,
|
||||
portfolioSummaryError,
|
||||
invalidatePortfolio,
|
||||
} = usePortfolioData()
|
||||
|
||||
// Market data hook (with throttled live prices and market update handler)
|
||||
const {
|
||||
livePrices,
|
||||
liveQuote,
|
||||
handleMarketUpdate,
|
||||
} = useMarketData(
|
||||
selectedStock,
|
||||
portfolio
|
||||
)
|
||||
|
||||
// WebSocket hook - pass both market and chart data handlers
|
||||
const { connected: wsConnected, messages: wsMessages, sendMessage } =
|
||||
useWebSocket('dashboard', {
|
||||
onMarketUpdate: handleMarketUpdate,
|
||||
onChartData: (msg: unknown) => {
|
||||
const m = msg as Record<string, unknown>
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
if (
|
||||
m?.stock_code !== selectedStockRef.current ||
|
||||
m?.period !== chartPeriodRef.current
|
||||
)
|
||||
return
|
||||
const data = ((m.data as Record<string, unknown>[]) || [])
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
date: item.date as string,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
}))
|
||||
.reverse()
|
||||
setChartData(data)
|
||||
setChartLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Trade actions hook
|
||||
const { handleSell, resetPendingTrades } = useTradeActions()
|
||||
|
||||
// Subscribe to market data via WebSocket
|
||||
useEffect(() => {
|
||||
if (!wsConnected) return
|
||||
const codes = [
|
||||
...new Set([
|
||||
...(portfolio || []).map((s: any) => s.stock_code as string),
|
||||
...(selectedStock ? [selectedStock] : []),
|
||||
]),
|
||||
]
|
||||
if (codes.length === 0) return
|
||||
sendMessage(
|
||||
JSON.stringify({ type: 'set_subscriptions', stock_codes: codes })
|
||||
)
|
||||
}, [wsConnected, portfolio, selectedStock, sendMessage])
|
||||
|
||||
// 호가창 - REST fallback (WebSocket quote로 실시간 갱신)
|
||||
const { data: orderbook } = useQuery({
|
||||
queryKey: ['orderbook', selectedStock],
|
||||
queryFn: async () => {
|
||||
const data = (await api.get('/portfolio/combined')).data;
|
||||
return {
|
||||
holdings: (data.holdings as any[]).filter((item) => item.quantity > 0),
|
||||
summary: data.summary,
|
||||
};
|
||||
if (!selectedStock) return null
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/market-data/orderbook/${selectedStock}`
|
||||
)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Orderbook fetch failed (${selectedStock}):`,
|
||||
error.response?.data?.detail || error.message
|
||||
)
|
||||
return null
|
||||
}
|
||||
},
|
||||
enabled: Boolean(selectedStock),
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
})
|
||||
const portfolio = portfolioCombined?.holdings
|
||||
const portfolioSummary = portfolioCombined?.summary
|
||||
|
||||
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
||||
useQuery({
|
||||
@@ -120,26 +159,36 @@ const Dashboard = () => {
|
||||
|
||||
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
|
||||
// trades_executed 발생 시: portfolio + trade-history 즉시 invalidate
|
||||
const { data: ruleBasedRecommendations, refetch: refetchRecommendations, isError: isRecommendationsError } = useQuery({
|
||||
const {
|
||||
data: ruleBasedRecommendations,
|
||||
refetch: refetchRecommendations,
|
||||
isError: isRecommendationsError,
|
||||
} = useQuery({
|
||||
queryKey: ['rule-based-recommendations'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/recommendations/rule-based', {
|
||||
params: {
|
||||
realtime: false,
|
||||
auto_trade: true
|
||||
}
|
||||
});
|
||||
auto_trade: true,
|
||||
},
|
||||
})
|
||||
|
||||
const data = response.data;
|
||||
const data = response.data
|
||||
|
||||
// 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거)
|
||||
if (data.trades_executed && data.trades_executed.length > 0) {
|
||||
console.log('✅ 자동매매 체결:', data.trades_executed.length, '건');
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||
console.log(
|
||||
'✅ 자동매매 체결:',
|
||||
data.trades_executed.length,
|
||||
'건'
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
invalidatePortfolio()
|
||||
}
|
||||
|
||||
return data;
|
||||
return data
|
||||
},
|
||||
refetchInterval: isMarketOpen ? 120000 : 300000,
|
||||
staleTime: isMarketOpen ? 110000 : 290000,
|
||||
@@ -152,40 +201,31 @@ const Dashboard = () => {
|
||||
if (!selectedStock || !wsConnected) return
|
||||
setChartLoading(true)
|
||||
setChartData(null)
|
||||
sendMessage(JSON.stringify({ type: 'get_chart', stock_code: selectedStock, period: chartPeriod, limit: 100 }))
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
type: 'get_chart',
|
||||
stock_code: selectedStock,
|
||||
period: chartPeriod,
|
||||
limit: 100,
|
||||
})
|
||||
)
|
||||
}, [selectedStock, chartPeriod, wsConnected, sendMessage])
|
||||
|
||||
// 호가창 - 10초 (실시간성 필요하지만 5초는 과도)
|
||||
const { data: orderbook } = useQuery({
|
||||
queryKey: ['orderbook', selectedStock],
|
||||
queryFn: async () => {
|
||||
if (!selectedStock) return null;
|
||||
try {
|
||||
const response = await api.get(`/market-data/orderbook/${selectedStock}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 호가 조회 실패 (${selectedStock}):`, error.response?.data?.detail || error.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: Boolean(selectedStock),
|
||||
staleTime: Infinity,
|
||||
// refetchInterval 제거 — WebSocket quote(H0STASP0)로 실시간 갱신
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// 거래 이력 - 30초 (자동매매 체결 감지용)
|
||||
const { data: tradeHistory } = useQuery({
|
||||
queryKey: ['trade-history'],
|
||||
queryFn: async () => {
|
||||
const data = (await api.get('/trades', { params: { limit: 50 } })).data;
|
||||
const data = (await api.get('/trades', { params: { limit: 50 } }))
|
||||
.data as TradeHistory[]
|
||||
|
||||
const seen = new Set();
|
||||
return data.filter((trade: any) => {
|
||||
if (seen.has(trade.id)) return false;
|
||||
seen.add(trade.id);
|
||||
return true;
|
||||
}).slice(0, 20);
|
||||
const seen = new Set<number>()
|
||||
return data
|
||||
.filter((trade: TradeHistory) => {
|
||||
if (seen.has(trade.id)) return false
|
||||
seen.add(trade.id)
|
||||
return true
|
||||
})
|
||||
.slice(0, 20)
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: isMarketOpen ? 30000 : false,
|
||||
@@ -195,7 +235,8 @@ const Dashboard = () => {
|
||||
// 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신)
|
||||
const { data: journals } = useQuery({
|
||||
queryKey: ['journals'],
|
||||
queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data,
|
||||
queryFn: async () =>
|
||||
(await api.get('/journals', { params: { limit: 20 } })).data,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
@@ -205,16 +246,24 @@ const Dashboard = () => {
|
||||
// tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!tradeHistory || tradeHistory.length === 0) return;
|
||||
const latestId = tradeHistory[0].id;
|
||||
if (!tradeHistory || tradeHistory.length === 0) return
|
||||
const latestId = tradeHistory[0].id
|
||||
|
||||
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
||||
const t = tradeHistory[0];
|
||||
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||
if (
|
||||
prevLatestTradeIdRef.current !== null &&
|
||||
latestId > prevLatestTradeIdRef.current
|
||||
) {
|
||||
const t = tradeHistory[0]
|
||||
console.log(
|
||||
'✅ 새 매매 감지:',
|
||||
t.stock_name,
|
||||
t.trade_type,
|
||||
'→ 포트폴리오 갱신'
|
||||
)
|
||||
invalidatePortfolio()
|
||||
}
|
||||
prevLatestTradeIdRef.current = latestId;
|
||||
}, [tradeHistory, queryClient])
|
||||
prevLatestTradeIdRef.current = latestId
|
||||
}, [tradeHistory, invalidatePortfolio])
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// WebSocket 이벤트 처리
|
||||
@@ -222,49 +271,19 @@ const Dashboard = () => {
|
||||
// - journal_created: 매매일지 즉시 갱신
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (wsMessages.length === 0) return;
|
||||
const latest = wsMessages[wsMessages.length - 1];
|
||||
if (wsMessages.length === 0) return
|
||||
const latest = wsMessages[wsMessages.length - 1]
|
||||
|
||||
if (latest.type === 'trade_executed') {
|
||||
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신');
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신')
|
||||
invalidatePortfolio()
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||
}
|
||||
|
||||
if (latest.type === 'journal_created') {
|
||||
queryClient.invalidateQueries({ queryKey: ['journals'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['journals'] })
|
||||
}
|
||||
}, [wsMessages, queryClient])
|
||||
|
||||
// 포트폴리오 로드 시 livePrices 초기값 설정 (REST current_price → 실시간 tick으로 덮어씌워짐)
|
||||
useEffect(() => {
|
||||
if (!portfolio) return
|
||||
setLivePrices(prev => {
|
||||
const next = { ...prev }
|
||||
; (portfolio as any[]).forEach((s: any) => {
|
||||
if (s.current_price && !next[s.stock_code]) {
|
||||
next[s.stock_code] = Number(s.current_price)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [portfolio])
|
||||
|
||||
// KIS WebSocket 구독: 보유 종목 + 선택 종목 → 실시간 tick/quote 수신
|
||||
useEffect(() => {
|
||||
if (!wsConnected) return
|
||||
const codes = [...new Set([
|
||||
...(portfolio || []).map((s: any) => s.stock_code as string),
|
||||
...(selectedStock ? [selectedStock] : []),
|
||||
])]
|
||||
if (codes.length === 0) return
|
||||
sendMessage(JSON.stringify({ type: 'set_subscriptions', stock_codes: codes }))
|
||||
}, [wsConnected, portfolio, selectedStock, sendMessage])
|
||||
|
||||
// 선택 종목 변경 시 이전 호가 초기화 (새 quote 도착 전까지 REST fallback 사용)
|
||||
useEffect(() => {
|
||||
setLiveQuote(null)
|
||||
}, [selectedStock])
|
||||
}, [wsMessages, invalidatePortfolio, queryClient])
|
||||
|
||||
// 실시간 주식평가금액/손익 — livePrices tick 기반 재계산
|
||||
// total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영
|
||||
@@ -273,28 +292,43 @@ const Dashboard = () => {
|
||||
|
||||
// live 현재가 기반 주식 평가금액
|
||||
const liveValue = portfolio.reduce((sum, s) => {
|
||||
const price = livePrices[s.stock_code] ?? Number(s.current_price || 0)
|
||||
const price =
|
||||
livePrices[s.stock_code] ?? Number(s.current_price || 0)
|
||||
return sum + price * Number(s.quantity || 0)
|
||||
}, 0)
|
||||
|
||||
// 총 매입금액 (평균단가 × 수량)
|
||||
const totalInvestment = portfolio.reduce((sum, s) =>
|
||||
sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0)
|
||||
const totalInvestment = portfolio.reduce(
|
||||
(sum, s) =>
|
||||
sum + Number(s.average_price || 0) * Number(s.quantity || 0),
|
||||
0
|
||||
)
|
||||
|
||||
// 평가손익 = 주식 평가금액 - 매입금액
|
||||
const liveProfitLoss = liveValue - totalInvestment
|
||||
const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0
|
||||
const liveProfitLossRate =
|
||||
totalInvestment > 0
|
||||
? (liveProfitLoss / totalInvestment) * 100
|
||||
: 0
|
||||
|
||||
// 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
|
||||
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
|
||||
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
|
||||
const backendStockValue = portfolioSummary.stock_value_from_api || 0
|
||||
const liveTotal = (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue)
|
||||
const liveTotal =
|
||||
(portfolioSummary.total_assets || 0) +
|
||||
(liveValue - backendStockValue)
|
||||
|
||||
// 금일 손익도 live 주식 변동분 반영
|
||||
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
|
||||
const liveTodayPL = bfdyTotal > 0 ? liveTotal - bfdyTotal : (portfolioSummary.today_profit_loss || 0)
|
||||
const liveTodayPLRate = bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 : (portfolioSummary.today_profit_loss_rate || 0)
|
||||
const liveTodayPL =
|
||||
bfdyTotal > 0
|
||||
? liveTotal - bfdyTotal
|
||||
: portfolioSummary.today_profit_loss || 0
|
||||
const liveTodayPLRate =
|
||||
bfdyTotal > 0
|
||||
? (liveTodayPL / bfdyTotal) * 100
|
||||
: portfolioSummary.today_profit_loss_rate || 0
|
||||
|
||||
return {
|
||||
...portfolioSummary,
|
||||
@@ -311,8 +345,9 @@ const Dashboard = () => {
|
||||
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
|
||||
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
|
||||
const livePrice = livePrices[selectedStock]
|
||||
const liveChartData = useMemo<any[] | null>(() => {
|
||||
if (!chartData || chartData.length === 0 || !livePrice) return chartData ?? null
|
||||
const liveChartData = useMemo<CandleData[] | null>(() => {
|
||||
if (!chartData || chartData.length === 0 || !livePrice)
|
||||
return chartData ?? null
|
||||
const last = chartData[chartData.length - 1]
|
||||
return [
|
||||
...chartData.slice(0, -1),
|
||||
@@ -329,7 +364,8 @@ const Dashboard = () => {
|
||||
const activeOrderbook = useMemo(() => {
|
||||
if (!liveQuote) return orderbook ?? null
|
||||
return {
|
||||
current_price: livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
|
||||
current_price:
|
||||
livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
|
||||
stock_name: orderbook?.stock_name,
|
||||
asks: [
|
||||
{ price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 },
|
||||
@@ -337,29 +373,29 @@ const Dashboard = () => {
|
||||
{ price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 },
|
||||
{ price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 },
|
||||
{ price: liveQuote.ask5, volume: liveQuote.ask_qty5 ?? 0 },
|
||||
].filter(a => a.price != null && a.price > 0),
|
||||
].filter((a) => a.price != null && a.price > 0),
|
||||
bids: [
|
||||
{ price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 },
|
||||
{ price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 },
|
||||
{ price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 },
|
||||
{ price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 },
|
||||
{ price: liveQuote.bid5, volume: liveQuote.bid_qty5 ?? 0 },
|
||||
].filter(b => b.price != null && b.price > 0),
|
||||
].filter((b) => b.price != null && b.price > 0),
|
||||
current_volume: 0,
|
||||
}
|
||||
}, [liveQuote, livePrice, orderbook])
|
||||
|
||||
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
||||
const heldStockCodes = useMemo<Set<string>>(
|
||||
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
|
||||
() => new Set((portfolio || []).map((s) => s.stock_code as string)),
|
||||
[portfolio]
|
||||
)
|
||||
|
||||
const pendingBuyStockCodes = useMemo<Set<string>>(
|
||||
() => new Set(
|
||||
(tradeHistory || [])
|
||||
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
||||
.map((t: any) => t.stock_code as string)
|
||||
.filter((t: TradeHistory) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
||||
.map((t: TradeHistory) => t.stock_code as string)
|
||||
),
|
||||
[tradeHistory]
|
||||
)
|
||||
@@ -374,24 +410,6 @@ const Dashboard = () => {
|
||||
)
|
||||
}, [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-combined'] })
|
||||
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'] });
|
||||
console.log('✅ 미체결 주문 정리 완료:', response.data);
|
||||
} catch (error: any) {
|
||||
console.error('미체결 주문 정리 실패:', error);
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
|
||||
queryKey: ['ai-analysis', selectedStock],
|
||||
@@ -439,12 +457,12 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
analysis += `\n🤖 분석 결과:\n`;
|
||||
const recommendMap: any = {
|
||||
const recommendMap: Record<string, string> = {
|
||||
'BUY': '💚 매수',
|
||||
'SELL': '❤️ 매도',
|
||||
'HOLD': '💛 관망'
|
||||
};
|
||||
const trendMap: any = {
|
||||
const trendMap: Record<string, string> = {
|
||||
'UP': '📈 상승',
|
||||
'DOWN': '📉 하락',
|
||||
'SIDEWAYS': '➡️ 횡보'
|
||||
@@ -465,10 +483,11 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
return { analysis, data };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = error as any;
|
||||
console.error('❌ AI 분석 실패:', error);
|
||||
return {
|
||||
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${error.response?.data?.detail || error.message}`,
|
||||
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${err.response?.data?.detail || err.message}`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user