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:
2026-03-10 17:26:27 +09:00
parent a477072bdc
commit dc9bc114da
10 changed files with 736 additions and 244 deletions

View File

@@ -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
};
}