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:
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
|
||||
interface PortfolioPanelProps {
|
||||
portfolio: any[] | undefined
|
||||
portfolio: Position[] | undefined
|
||||
livePrices: Record<string, number> | undefined
|
||||
selectedStock: string
|
||||
onSelectStock: (code: string) => void
|
||||
@@ -31,7 +32,7 @@ const PortfolioPanel = React.memo(({
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
{portfolio && portfolio.length > 0 ? (
|
||||
portfolio.map((stock: any) => {
|
||||
portfolio.map((stock: Position) => {
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
@@ -40,13 +41,14 @@ const PortfolioPanel = React.memo(({
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent) => {
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: any) {
|
||||
alert(`매도 실패: ${error.response?.data?.detail || error.message}`)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any;
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react'
|
||||
import { Brain, RefreshCw } from 'lucide-react'
|
||||
import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading'
|
||||
|
||||
const formatTime = (isoStr?: string) => {
|
||||
const formatTime = (isoStr?: string): string | null => {
|
||||
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
|
||||
recommendations: RecommendationsData | undefined
|
||||
isError: boolean
|
||||
onRefetch: () => void
|
||||
selectedStock: string
|
||||
@@ -69,8 +70,8 @@ const RecommendationPanel = React.memo(({
|
||||
<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)
|
||||
(recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => {
|
||||
const isRecommended = (recommendations?.recommendations || []).some((r: Recommendation) => 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
|
||||
@@ -118,7 +119,7 @@ const RecommendationPanel = React.memo(({
|
||||
<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>}
|
||||
{(stock.score ?? 0) > 0 && <span className="text-purple-400">{stock.score}점</span>}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts';
|
||||
import {
|
||||
@@ -7,9 +6,10 @@ import {
|
||||
calculateRSI,
|
||||
calculateMACD
|
||||
} from '../utils/technicalIndicators';
|
||||
import { CandleData } from '../types/trading';
|
||||
|
||||
interface TradingChartProps {
|
||||
data: any[];
|
||||
data: CandleData[];
|
||||
}
|
||||
|
||||
// 한국 시장 색상 규칙
|
||||
@@ -27,7 +27,7 @@ const COLORS = {
|
||||
grid: '#1a1f2e',
|
||||
text: '#9ca3af',
|
||||
border: '#374151',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
// Refs for chart containers
|
||||
@@ -64,7 +64,8 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
};
|
||||
|
||||
// 데이터 계산 (메모이제이션)
|
||||
const processedData = useMemo(() => {
|
||||
// Note: lightweight-charts has strict Time type requirements, using 'any' for compatibility
|
||||
const processedData = useMemo<any>(() => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
// 데이터 정렬 (시간 오름차순)
|
||||
@@ -73,7 +74,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
const timeB = new Date(b.date).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
const closes = sortedData.map(d => d.close);
|
||||
const closes = sortedData.map((d: CandleData) => d.close);
|
||||
|
||||
// 기술적 지표 계산
|
||||
const bollinger = calculateBollingerBands(closes, 20);
|
||||
@@ -83,7 +84,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
const { macd, signal, histogram } = calculateMACD(closes);
|
||||
|
||||
// 캔들스틱 데이터 변환 (날짜 문자열을 YYYY-MM-DD 형식으로 변환)
|
||||
const candles: CandlestickData[] = sortedData.map(item => ({
|
||||
const candles: CandlestickData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
open: item.open,
|
||||
high: item.high,
|
||||
@@ -92,7 +93,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
}));
|
||||
|
||||
// 거래량 데이터 (한국 시장 색상 적용)
|
||||
const volumes: HistogramData[] = sortedData.map(item => ({
|
||||
const volumes: HistogramData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: item.volume,
|
||||
color: item.close >= item.open
|
||||
@@ -102,87 +103,87 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
|
||||
// 볼린저 밴드 데이터
|
||||
const bbUpperData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.upper[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const bbMiddleData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.middle[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const bbLowerData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.lower[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
// 이동평균선 데이터
|
||||
const sma5Data: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma5[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const sma60Data: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma60[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
// RSI 데이터
|
||||
const rsiData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: rsi[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30: LineData[] = sortedData.map(item => ({
|
||||
const rsiLine30: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 30,
|
||||
}));
|
||||
const rsiLine50: LineData[] = sortedData.map(item => ({
|
||||
const rsiLine50: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 50,
|
||||
}));
|
||||
const rsiLine70: LineData[] = sortedData.map(item => ({
|
||||
const rsiLine70: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 70,
|
||||
}));
|
||||
|
||||
// MACD 데이터
|
||||
const macdHistogramData: HistogramData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: histogram[i],
|
||||
color: histogram[i] >= 0
|
||||
? 'rgba(239, 68, 68, 0.6)' // 양수: 빨강
|
||||
: 'rgba(59, 130, 246, 0.6)' // 음수: 파랑
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: HistogramData) => !isNaN(d.value));
|
||||
|
||||
const macdLineData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: macd[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const signalLineData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: signal[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
return {
|
||||
candles,
|
||||
@@ -196,7 +197,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
}, [data]);
|
||||
|
||||
// 공통 차트 옵션
|
||||
const getChartOptions = (height: number) => ({
|
||||
const getChartOptions = (height: number): Record<string, any> => ({
|
||||
layout: {
|
||||
background: { color: COLORS.background },
|
||||
textColor: COLORS.text,
|
||||
@@ -252,47 +253,47 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
});
|
||||
|
||||
// 캔들스틱 시리즈 (한국 시장 색상)
|
||||
const candlestickSeries = (mainChart ).addCandlestickSeries({
|
||||
const candlestickSeries = mainChart.addCandlestickSeries({
|
||||
upColor: COLORS.candleUp,
|
||||
downColor: COLORS.candleDown,
|
||||
borderVisible: false,
|
||||
wickUpColor: COLORS.candleUp,
|
||||
wickDownColor: COLORS.candleDown,
|
||||
});
|
||||
candlestickSeries.setData(processedData.candles);
|
||||
candlestickSeries.setData((processedData as any)?.candles || []);
|
||||
|
||||
// 볼린저 밴드
|
||||
const bbUpperSeries = (mainChart ).addLineSeries({
|
||||
const bbUpperSeries = mainChart.addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbUpperSeries.setData(processedData.bollinger.upper);
|
||||
bbUpperSeries.setData((processedData as any).bollinger.upper);
|
||||
|
||||
const bbMiddleSeries = (mainChart ).addLineSeries({
|
||||
const bbMiddleSeries = mainChart.addLineSeries({
|
||||
color: COLORS.ma20,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbMiddleSeries.setData(processedData.bollinger.middle);
|
||||
bbMiddleSeries.setData((processedData as any).bollinger.middle);
|
||||
|
||||
const bbLowerSeries = (mainChart ).addLineSeries({
|
||||
const bbLowerSeries = mainChart.addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbLowerSeries.setData(processedData.bollinger.lower);
|
||||
bbLowerSeries.setData((processedData as any).bollinger.lower);
|
||||
|
||||
// 5일 이동평균선
|
||||
const sma5Series = (mainChart ).addLineSeries({
|
||||
const sma5Series = mainChart.addLineSeries({
|
||||
color: COLORS.ma5,
|
||||
lineWidth: 1.5,
|
||||
lineWidth: 2,
|
||||
});
|
||||
sma5Series.setData(processedData.sma5);
|
||||
sma5Series.setData((processedData as any).sma5);
|
||||
|
||||
// 60일 이동평균선
|
||||
const sma60Series = (mainChart ).addLineSeries({
|
||||
const sma60Series = mainChart.addLineSeries({
|
||||
color: COLORS.ma60,
|
||||
lineWidth: 1.5,
|
||||
lineWidth: 2,
|
||||
});
|
||||
sma60Series.setData(processedData.sma60);
|
||||
sma60Series.setData((processedData as any).sma60);
|
||||
|
||||
// === 2. 거래량 차트 ===
|
||||
const volumeChart = createChart(volumeChartRef.current, {
|
||||
@@ -300,12 +301,12 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
const volumeSeries = (volumeChart ).addHistogramSeries({
|
||||
const volumeSeries = volumeChart.addHistogramSeries({
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
});
|
||||
volumeSeries.setData(processedData.volumes);
|
||||
volumeSeries.setData((processedData as any).volumes);
|
||||
|
||||
// === 3. RSI 차트 ===
|
||||
const rsiChart = createChart(rsiChartRef.current, {
|
||||
@@ -314,39 +315,39 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
});
|
||||
|
||||
// RSI 라인
|
||||
const rsiSeries = (rsiChart ).addLineSeries({
|
||||
const rsiSeries = rsiChart.addLineSeries({
|
||||
color: COLORS.rsi,
|
||||
lineWidth: 2,
|
||||
});
|
||||
rsiSeries.setData(processedData.rsi.data);
|
||||
rsiSeries.setData((processedData as any).rsi.data);
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30Series = (rsiChart ).addLineSeries({
|
||||
const rsiLine30Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2, // dashed
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine30Series.setData(processedData.rsi.line30);
|
||||
rsiLine30Series.setData((processedData as any).rsi.line30);
|
||||
|
||||
const rsiLine50Series = (rsiChart ).addLineSeries({
|
||||
const rsiLine50Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine50Series.setData(processedData.rsi.line50);
|
||||
rsiLine50Series.setData((processedData as any).rsi.line50);
|
||||
|
||||
const rsiLine70Series = (rsiChart ).addLineSeries({
|
||||
const rsiLine70Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine70Series.setData(processedData.rsi.line70);
|
||||
rsiLine70Series.setData((processedData as any).rsi.line70);
|
||||
|
||||
// RSI Y축 범위 고정 (0-100)
|
||||
(rsiChart ).priceScale('right').applyOptions({
|
||||
rsiChart.priceScale('right').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.1,
|
||||
@@ -360,22 +361,22 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
});
|
||||
|
||||
// MACD 히스토그램
|
||||
const macdHistogramSeries = (macdChart ).addHistogramSeries();
|
||||
macdHistogramSeries.setData(processedData.macd.histogram);
|
||||
const macdHistogramSeries = macdChart.addHistogramSeries();
|
||||
macdHistogramSeries.setData((processedData as any).macd.histogram);
|
||||
|
||||
// MACD 라인
|
||||
const macdLineSeries = (macdChart ).addLineSeries({
|
||||
const macdLineSeries = macdChart.addLineSeries({
|
||||
color: COLORS.macd,
|
||||
lineWidth: 1.5,
|
||||
lineWidth: 2,
|
||||
});
|
||||
macdLineSeries.setData(processedData.macd.line);
|
||||
macdLineSeries.setData((processedData as any).macd.line);
|
||||
|
||||
// Signal 라인
|
||||
const signalLineSeries = (macdChart ).addLineSeries({
|
||||
const signalLineSeries = macdChart.addLineSeries({
|
||||
color: COLORS.signal,
|
||||
lineWidth: 1.5,
|
||||
lineWidth: 2,
|
||||
});
|
||||
signalLineSeries.setData(processedData.macd.signal);
|
||||
signalLineSeries.setData((processedData as any).macd.signal);
|
||||
|
||||
// === 시간축 동기화 ===
|
||||
// 초기 visible range 설정 (모든 차트가 동일한 범위를 보이도록)
|
||||
@@ -459,11 +460,12 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
// Chart already disposed
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error | null;
|
||||
console.error('❌ 차트 렌더링 중 오류 발생:', error);
|
||||
console.error('오류 상세:', {
|
||||
message: error?.message || '알 수 없는 오류',
|
||||
stack: error?.stack,
|
||||
message: err?.message || '알 수 없는 오류',
|
||||
stack: err?.stack,
|
||||
processedData: processedData,
|
||||
});
|
||||
}
|
||||
|
||||
65
frontend/src/hooks/useMarketData.ts
Normal file
65
frontend/src/hooks/useMarketData.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
|
||||
export const useMarketData = (
|
||||
selectedStock: string,
|
||||
portfolio: any[] | undefined
|
||||
) => {
|
||||
// Live prices with throttling to prevent excessive re-renders
|
||||
const [livePrices, setLivePrices] = useState<Record<string, number>>({})
|
||||
const [liveQuote, setLiveQuote] = useState<any>(null)
|
||||
|
||||
// Throttle ref: track last update time per stock
|
||||
const lastPriceUpdateRef = useRef<Record<string, number>>({})
|
||||
const throttleDelayMs = 500 // Max 2-3 price updates per second (500ms = 2/sec)
|
||||
|
||||
// Handle market updates (tick data) with throttling
|
||||
const handleMarketUpdate = useCallback((data: unknown) => {
|
||||
const d = data as any
|
||||
if (d?._type === 'tick' && d.stock_code && d.price != null) {
|
||||
const now = Date.now()
|
||||
const lastUpdate =
|
||||
lastPriceUpdateRef.current[d.stock_code] ?? 0
|
||||
|
||||
// Only update if throttle delay has passed
|
||||
if (now - lastUpdate >= throttleDelayMs) {
|
||||
setLivePrices((prev) => ({
|
||||
...prev,
|
||||
[d.stock_code]: d.price,
|
||||
}))
|
||||
lastPriceUpdateRef.current[d.stock_code] = now
|
||||
}
|
||||
} else if (
|
||||
d?._type === 'quote' &&
|
||||
d.stock_code === selectedStock
|
||||
) {
|
||||
setLiveQuote(d)
|
||||
}
|
||||
}, [selectedStock])
|
||||
|
||||
|
||||
// Initialize live prices from portfolio
|
||||
useEffect(() => {
|
||||
if (!portfolio) return
|
||||
setLivePrices((prev) => {
|
||||
const next = { ...prev }
|
||||
portfolio.forEach((s: any) => {
|
||||
if (s.current_price && !next[s.stock_code]) {
|
||||
next[s.stock_code] = Number(s.current_price)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [portfolio])
|
||||
|
||||
// Clear live quote when selected stock changes
|
||||
useEffect(() => {
|
||||
setLiveQuote(null)
|
||||
}, [selectedStock])
|
||||
|
||||
|
||||
return {
|
||||
livePrices,
|
||||
liveQuote,
|
||||
handleMarketUpdate,
|
||||
}
|
||||
}
|
||||
48
frontend/src/hooks/usePortfolioData.ts
Normal file
48
frontend/src/hooks/usePortfolioData.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { Position, PortfolioSummary } from '../types/trading'
|
||||
|
||||
export interface PortfolioCombinedData {
|
||||
holdings: Position[]
|
||||
summary: PortfolioSummary
|
||||
}
|
||||
|
||||
export const usePortfolioData = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: portfolioCombined,
|
||||
error: portfolioSummaryError,
|
||||
isLoading: isPortfolioLoading,
|
||||
refetch: refetchPortfolio,
|
||||
} = useQuery({
|
||||
queryKey: ['portfolio-combined'],
|
||||
queryFn: async () => {
|
||||
const data = (await api.get('/portfolio/combined')).data
|
||||
return {
|
||||
holdings: (data.holdings as Position[]).filter(
|
||||
(item) => item.quantity > 0
|
||||
),
|
||||
summary: data.summary as PortfolioSummary,
|
||||
}
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const portfolio = portfolioCombined?.holdings
|
||||
const portfolioSummary = portfolioCombined?.summary
|
||||
|
||||
const invalidatePortfolio = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
|
||||
}
|
||||
|
||||
return {
|
||||
portfolio,
|
||||
portfolioSummary,
|
||||
portfolioCombined,
|
||||
isPortfolioLoading,
|
||||
portfolioSummaryError,
|
||||
refetchPortfolio,
|
||||
invalidatePortfolio,
|
||||
}
|
||||
}
|
||||
68
frontend/src/hooks/useTradeActions.ts
Normal file
68
frontend/src/hooks/useTradeActions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
export const useTradeActions = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleSell = useCallback(
|
||||
async (
|
||||
stockCode: string,
|
||||
stockName: string,
|
||||
quantity: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await api.post('/portfolio/sell', {
|
||||
stock_code: stockCode,
|
||||
quantity,
|
||||
price: null,
|
||||
})
|
||||
|
||||
// Invalidate queries to refresh portfolio
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['portfolio-combined'],
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
|
||||
console.log(`✅ ${stockName} sell order completed`)
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`❌ Sell order failed for ${stockName}:`,
|
||||
error.response?.data?.detail || error.message
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
)
|
||||
|
||||
const resetPendingTrades = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await api.delete('/trades/pending')
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
console.log('✅ Pending orders cleared:', response.data)
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
'❌ Failed to clear pending orders:',
|
||||
error.response?.data?.detail || error.message
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
const invalidateTradeHistory = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
return {
|
||||
handleSell,
|
||||
resetPendingTrades,
|
||||
invalidateTradeHistory,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
const [connected, setConnected] = useState(false)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
@@ -25,6 +26,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
||||
const onChartDataRef = useRef(options?.onChartData)
|
||||
const baseDelay = 3000
|
||||
const lastPongReceivedRef = useRef<number>(Date.now())
|
||||
const pongTimeoutMs = 10000 // 10초 이내에 pong을 받지 못하면 연결 끊김으로 간주
|
||||
|
||||
// options 변경 시 ref 동기화
|
||||
useEffect(() => {
|
||||
@@ -42,11 +45,25 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
console.log('🔗 WebSocket connected')
|
||||
setConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
lastPongReceivedRef.current = Date.now()
|
||||
|
||||
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
|
||||
// Pong 타임아웃 설정 (10초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current)
|
||||
}
|
||||
|
||||
pongTimeoutRef.current = setTimeout(() => {
|
||||
const timeSinceLastPong = Date.now() - lastPongReceivedRef.current
|
||||
console.warn(
|
||||
`⏱️ Pong timeout: No pong received for ${timeSinceLastPong}ms. Closing connection.`
|
||||
)
|
||||
ws.close()
|
||||
}, pongTimeoutMs)
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
@@ -54,7 +71,15 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage
|
||||
if (message.type === 'pong') return
|
||||
if (message.type === 'pong') {
|
||||
// Pong 수신 — 타임아웃 타이머 취소 및 마지막 pong 시간 업데이트
|
||||
lastPongReceivedRef.current = Date.now()
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current)
|
||||
pongTimeoutRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// market_update: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신)
|
||||
if (message.type === 'market_update') {
|
||||
@@ -88,6 +113,11 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
pingIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current)
|
||||
pongTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
|
||||
if (mountedRef.current) {
|
||||
const attempt = reconnectAttemptsRef.current
|
||||
@@ -118,6 +148,9 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current)
|
||||
}
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
71
frontend/src/types/lightweight-charts.d.ts
vendored
Normal file
71
frontend/src/types/lightweight-charts.d.ts
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Type declarations for lightweight-charts library
|
||||
*/
|
||||
|
||||
export interface BarData {
|
||||
time: string | number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface CandlestickData {
|
||||
time: string | number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface LineData {
|
||||
time: string | number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface HistogramData {
|
||||
time: string | number;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface AreaData {
|
||||
time: string | number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum CrosshairMode {
|
||||
Normal = 0,
|
||||
Magnet = 1,
|
||||
Hidden = 2,
|
||||
}
|
||||
|
||||
export interface ISeriesApi<T extends string = 'Candlestick'> {
|
||||
setData(data: CandlestickData[] | LineData[] | HistogramData[] | BarData[]): void;
|
||||
update(data: CandlestickData | LineData | HistogramData | BarData): void;
|
||||
}
|
||||
|
||||
export interface IPriceScaleApi {
|
||||
applyOptions(options: any): void;
|
||||
}
|
||||
|
||||
export interface ITimeScaleApi {
|
||||
fitContent(): void;
|
||||
getVisibleRange(): { from: number; to: number } | null;
|
||||
setVisibleRange(range: { from: number; to: number }): void;
|
||||
subscribeVisibleTimeRangeChange(callback: (range: { from: number; to: number } | null) => void): void;
|
||||
}
|
||||
|
||||
export interface IChartApi {
|
||||
addCandlestickSeries(options?: any): ISeriesApi<'Candlestick'>;
|
||||
addLineSeries(options?: any): ISeriesApi<'Line'>;
|
||||
addHistogramSeries(options?: any): ISeriesApi<'Histogram'>;
|
||||
addBarSeries(options?: any): ISeriesApi<'Bar'>;
|
||||
addAreaSeries(options?: any): ISeriesApi<'Area'>;
|
||||
priceScale(position: string): IPriceScaleApi;
|
||||
timeScale(): ITimeScaleApi;
|
||||
applyOptions(options: any): void;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export function createChart(container: HTMLElement, options?: any): IChartApi;
|
||||
183
frontend/src/types/trading.ts
Normal file
183
frontend/src/types/trading.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Shared type definitions for trading application
|
||||
*/
|
||||
|
||||
// Market Data Types
|
||||
export interface CandleData {
|
||||
date: string | Date;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface MarketData {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
current_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
volume: number;
|
||||
change_rate: number;
|
||||
indicators?: {
|
||||
rsi?: number | null;
|
||||
ma5?: number | null;
|
||||
ma20?: number | null;
|
||||
ma60?: number | null;
|
||||
stochastic_k?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
};
|
||||
recommendation?: 'BUY' | 'SELL' | 'HOLD';
|
||||
confidence?: number;
|
||||
trend?: 'UP' | 'DOWN' | 'SIDEWAYS';
|
||||
ai_analysis?: string;
|
||||
}
|
||||
|
||||
// Portfolio Types
|
||||
export interface Position {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
quantity: number;
|
||||
average_price: number;
|
||||
current_price: number;
|
||||
profit_loss_rate: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
total_assets: number;
|
||||
bfdy_total_assets: number;
|
||||
today_profit_loss: number;
|
||||
today_profit_loss_rate: number;
|
||||
stock_value_from_api: number;
|
||||
total_value?: number;
|
||||
total_investment?: number;
|
||||
total_profit_loss?: number;
|
||||
total_profit_loss_rate?: number;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
export interface PortfolioCombined {
|
||||
holdings: Position[];
|
||||
summary: PortfolioSummary;
|
||||
}
|
||||
|
||||
// Trade Types
|
||||
export interface Trade {
|
||||
id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
trade_type: 'BUY' | 'SELL';
|
||||
status: 'PENDING' | 'EXECUTED' | 'CANCELLED';
|
||||
quantity: number;
|
||||
price: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface TradeHistory extends Trade {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Recommendation Types
|
||||
export interface RecommendedStock {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
current_price: number;
|
||||
change_rate: number;
|
||||
signals?: string[];
|
||||
rsi?: number | null;
|
||||
stochastic_k?: number | null;
|
||||
score?: number;
|
||||
[key: string]: string | number | null | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
stock_code: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface RecommendationsData {
|
||||
analyzed_stocks: number;
|
||||
analyzed_stock_list: RecommendedStock[];
|
||||
recommendations: Recommendation[];
|
||||
batch_time?: string;
|
||||
trades_executed?: Trade[];
|
||||
recommendations_by_rule?: Array<{
|
||||
rule_name: string;
|
||||
stocks: RecommendedStock[];
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Journal Types
|
||||
export interface Journal {
|
||||
id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
// Orderbook Types
|
||||
export interface OrderbookLevel {
|
||||
price: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface Orderbook {
|
||||
stock_name?: string;
|
||||
current_price: number;
|
||||
current_volume: number;
|
||||
asks: OrderbookLevel[];
|
||||
bids: OrderbookLevel[];
|
||||
}
|
||||
|
||||
// Chart Data Types
|
||||
export interface ProcessedChartData {
|
||||
candles: Array<{ time: string | number; open: number; high: number; low: number; close: number }>;
|
||||
volumes: Array<{ time: string | number; value: number; color: string }>;
|
||||
bollinger: {
|
||||
upper: Array<{ time: string | number; value: number }>;
|
||||
middle: Array<{ time: string | number; value: number }>;
|
||||
lower: Array<{ time: string | number; value: number }>;
|
||||
};
|
||||
sma5: Array<{ time: string | number; value: number }>;
|
||||
sma60: Array<{ time: string | number; value: number }>;
|
||||
rsi: {
|
||||
data: Array<{ time: string | number; value: number }>;
|
||||
line30: Array<{ time: string | number; value: number }>;
|
||||
line50: Array<{ time: string | number; value: number }>;
|
||||
line70: Array<{ time: string | number; value: number }>;
|
||||
};
|
||||
macd: {
|
||||
histogram: Array<{ time: string | number; value: number; color: string }>;
|
||||
line: Array<{ time: string | number; value: number }>;
|
||||
signal: Array<{ time: string | number; value: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
export interface MarketUpdateMessage {
|
||||
_type: 'tick' | 'quote';
|
||||
stock_code?: string;
|
||||
price?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ChartDataMessage {
|
||||
type: 'chart_data';
|
||||
stock_code: string;
|
||||
period: 'D' | 'W' | 'M';
|
||||
data: CandleData[];
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// AI Analysis Types
|
||||
export interface AiAnalysisResult {
|
||||
analysis: string;
|
||||
data: MarketData | null;
|
||||
}
|
||||
Reference in New Issue
Block a user