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 React from 'react'
|
||||||
import { Activity } from 'lucide-react'
|
import { Activity } from 'lucide-react'
|
||||||
|
import { Position } from '../types/trading'
|
||||||
|
|
||||||
interface PortfolioPanelProps {
|
interface PortfolioPanelProps {
|
||||||
portfolio: any[] | undefined
|
portfolio: Position[] | undefined
|
||||||
livePrices: Record<string, number> | undefined
|
livePrices: Record<string, number> | undefined
|
||||||
selectedStock: string
|
selectedStock: string
|
||||||
onSelectStock: (code: string) => void
|
onSelectStock: (code: string) => void
|
||||||
@@ -31,7 +32,7 @@ const PortfolioPanel = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||||
{portfolio && portfolio.length > 0 ? (
|
{portfolio && portfolio.length > 0 ? (
|
||||||
portfolio.map((stock: any) => {
|
portfolio.map((stock: Position) => {
|
||||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||||
const avgPrice = Number(stock.average_price || 0)
|
const avgPrice = Number(stock.average_price || 0)
|
||||||
const quantity = Number(stock.quantity || 0)
|
const quantity = Number(stock.quantity || 0)
|
||||||
@@ -40,13 +41,14 @@ const PortfolioPanel = React.memo(({
|
|||||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||||
: Number(stock.profit_loss_rate || 0)
|
: Number(stock.profit_loss_rate || 0)
|
||||||
|
|
||||||
const handleSellClick = async (e: React.MouseEvent) => {
|
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
alert(`매도 실패: ${error.response?.data?.detail || error.message}`)
|
const err = error as any;
|
||||||
|
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Brain, RefreshCw } from 'lucide-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
|
if (!isoStr) return null
|
||||||
const d = new Date(isoStr)
|
const d = new Date(isoStr)
|
||||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecommendationPanelProps {
|
interface RecommendationPanelProps {
|
||||||
recommendations: any | undefined
|
recommendations: RecommendationsData | undefined
|
||||||
isError: boolean
|
isError: boolean
|
||||||
onRefetch: () => void
|
onRefetch: () => void
|
||||||
selectedStock: string
|
selectedStock: string
|
||||||
@@ -69,8 +70,8 @@ const RecommendationPanel = React.memo(({
|
|||||||
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
|
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
|
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
|
||||||
(recommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => {
|
(recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => {
|
||||||
const isRecommended = (recommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code)
|
const isRecommended = (recommendations?.recommendations || []).some((r: Recommendation) => r.stock_code === stock.stock_code)
|
||||||
const isHeld = heldStockCodes.has(stock.stock_code)
|
const isHeld = heldStockCodes.has(stock.stock_code)
|
||||||
const isPending = pendingBuyStockCodes.has(stock.stock_code)
|
const isPending = pendingBuyStockCodes.has(stock.stock_code)
|
||||||
const changeRate = stock.change_rate || 0
|
const changeRate = stock.change_rate || 0
|
||||||
@@ -118,7 +119,7 @@ const RecommendationPanel = React.memo(({
|
|||||||
<span className="flex gap-2">
|
<span className="flex gap-2">
|
||||||
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
|
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
|
||||||
{stock.stochastic_k != null && <span>K {stock.stochastic_k.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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo } from 'react';
|
||||||
import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts';
|
import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts';
|
||||||
import {
|
import {
|
||||||
@@ -7,9 +6,10 @@ import {
|
|||||||
calculateRSI,
|
calculateRSI,
|
||||||
calculateMACD
|
calculateMACD
|
||||||
} from '../utils/technicalIndicators';
|
} from '../utils/technicalIndicators';
|
||||||
|
import { CandleData } from '../types/trading';
|
||||||
|
|
||||||
interface TradingChartProps {
|
interface TradingChartProps {
|
||||||
data: any[];
|
data: CandleData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 한국 시장 색상 규칙
|
// 한국 시장 색상 규칙
|
||||||
@@ -27,7 +27,7 @@ const COLORS = {
|
|||||||
grid: '#1a1f2e',
|
grid: '#1a1f2e',
|
||||||
text: '#9ca3af',
|
text: '#9ca3af',
|
||||||
border: '#374151',
|
border: '#374151',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export const TradingChart = ({ data }: TradingChartProps) => {
|
export const TradingChart = ({ data }: TradingChartProps) => {
|
||||||
// Refs for chart containers
|
// 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;
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
// 데이터 정렬 (시간 오름차순)
|
// 데이터 정렬 (시간 오름차순)
|
||||||
@@ -73,7 +74,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
const timeB = new Date(b.date).getTime();
|
const timeB = new Date(b.date).getTime();
|
||||||
return timeA - timeB;
|
return timeA - timeB;
|
||||||
});
|
});
|
||||||
const closes = sortedData.map(d => d.close);
|
const closes = sortedData.map((d: CandleData) => d.close);
|
||||||
|
|
||||||
// 기술적 지표 계산
|
// 기술적 지표 계산
|
||||||
const bollinger = calculateBollingerBands(closes, 20);
|
const bollinger = calculateBollingerBands(closes, 20);
|
||||||
@@ -83,7 +84,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
const { macd, signal, histogram } = calculateMACD(closes);
|
const { macd, signal, histogram } = calculateMACD(closes);
|
||||||
|
|
||||||
// 캔들스틱 데이터 변환 (날짜 문자열을 YYYY-MM-DD 형식으로 변환)
|
// 캔들스틱 데이터 변환 (날짜 문자열을 YYYY-MM-DD 형식으로 변환)
|
||||||
const candles: CandlestickData[] = sortedData.map(item => ({
|
const candles: CandlestickData[] = sortedData.map((item: CandleData) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
open: item.open,
|
open: item.open,
|
||||||
high: item.high,
|
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,
|
time: toChartTime(item.date) as any,
|
||||||
value: item.volume,
|
value: item.volume,
|
||||||
color: item.close >= item.open
|
color: item.close >= item.open
|
||||||
@@ -102,87 +103,87 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
|
|
||||||
// 볼린저 밴드 데이터
|
// 볼린저 밴드 데이터
|
||||||
const bbUpperData: LineData[] = sortedData
|
const bbUpperData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: bollinger.upper[i],
|
value: bollinger.upper[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
const bbMiddleData: LineData[] = sortedData
|
const bbMiddleData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: bollinger.middle[i],
|
value: bollinger.middle[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
const bbLowerData: LineData[] = sortedData
|
const bbLowerData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: bollinger.lower[i],
|
value: bollinger.lower[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
// 이동평균선 데이터
|
// 이동평균선 데이터
|
||||||
const sma5Data: LineData[] = sortedData
|
const sma5Data: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: sma5[i],
|
value: sma5[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
const sma60Data: LineData[] = sortedData
|
const sma60Data: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: sma60[i],
|
value: sma60[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
// RSI 데이터
|
// RSI 데이터
|
||||||
const rsiData: LineData[] = sortedData
|
const rsiData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: rsi[i],
|
value: rsi[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
// RSI 기준선 (30, 50, 70)
|
// RSI 기준선 (30, 50, 70)
|
||||||
const rsiLine30: LineData[] = sortedData.map(item => ({
|
const rsiLine30: LineData[] = sortedData.map((item: CandleData) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: 30,
|
value: 30,
|
||||||
}));
|
}));
|
||||||
const rsiLine50: LineData[] = sortedData.map(item => ({
|
const rsiLine50: LineData[] = sortedData.map((item: CandleData) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: 50,
|
value: 50,
|
||||||
}));
|
}));
|
||||||
const rsiLine70: LineData[] = sortedData.map(item => ({
|
const rsiLine70: LineData[] = sortedData.map((item: CandleData) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: 70,
|
value: 70,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// MACD 데이터
|
// MACD 데이터
|
||||||
const macdHistogramData: HistogramData[] = sortedData
|
const macdHistogramData: HistogramData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: histogram[i],
|
value: histogram[i],
|
||||||
color: histogram[i] >= 0
|
color: histogram[i] >= 0
|
||||||
? 'rgba(239, 68, 68, 0.6)' // 양수: 빨강
|
? 'rgba(239, 68, 68, 0.6)' // 양수: 빨강
|
||||||
: 'rgba(59, 130, 246, 0.6)' // 음수: 파랑
|
: 'rgba(59, 130, 246, 0.6)' // 음수: 파랑
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: HistogramData) => !isNaN(d.value));
|
||||||
|
|
||||||
const macdLineData: LineData[] = sortedData
|
const macdLineData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: macd[i],
|
value: macd[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
const signalLineData: LineData[] = sortedData
|
const signalLineData: LineData[] = sortedData
|
||||||
.map((item, i) => ({
|
.map((item: CandleData, i: number) => ({
|
||||||
time: toChartTime(item.date) as any,
|
time: toChartTime(item.date) as any,
|
||||||
value: signal[i],
|
value: signal[i],
|
||||||
}))
|
}))
|
||||||
.filter(d => !isNaN(d.value));
|
.filter((d: LineData) => !isNaN(d.value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
candles,
|
candles,
|
||||||
@@ -196,7 +197,7 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// 공통 차트 옵션
|
// 공통 차트 옵션
|
||||||
const getChartOptions = (height: number) => ({
|
const getChartOptions = (height: number): Record<string, any> => ({
|
||||||
layout: {
|
layout: {
|
||||||
background: { color: COLORS.background },
|
background: { color: COLORS.background },
|
||||||
textColor: COLORS.text,
|
textColor: COLORS.text,
|
||||||
@@ -252,47 +253,47 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 캔들스틱 시리즈 (한국 시장 색상)
|
// 캔들스틱 시리즈 (한국 시장 색상)
|
||||||
const candlestickSeries = (mainChart ).addCandlestickSeries({
|
const candlestickSeries = mainChart.addCandlestickSeries({
|
||||||
upColor: COLORS.candleUp,
|
upColor: COLORS.candleUp,
|
||||||
downColor: COLORS.candleDown,
|
downColor: COLORS.candleDown,
|
||||||
borderVisible: false,
|
borderVisible: false,
|
||||||
wickUpColor: COLORS.candleUp,
|
wickUpColor: COLORS.candleUp,
|
||||||
wickDownColor: COLORS.candleDown,
|
wickDownColor: COLORS.candleDown,
|
||||||
});
|
});
|
||||||
candlestickSeries.setData(processedData.candles);
|
candlestickSeries.setData((processedData as any)?.candles || []);
|
||||||
|
|
||||||
// 볼린저 밴드
|
// 볼린저 밴드
|
||||||
const bbUpperSeries = (mainChart ).addLineSeries({
|
const bbUpperSeries = mainChart.addLineSeries({
|
||||||
color: COLORS.bbLine,
|
color: COLORS.bbLine,
|
||||||
lineWidth: 1,
|
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,
|
color: COLORS.ma20,
|
||||||
lineWidth: 1,
|
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,
|
color: COLORS.bbLine,
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
});
|
});
|
||||||
bbLowerSeries.setData(processedData.bollinger.lower);
|
bbLowerSeries.setData((processedData as any).bollinger.lower);
|
||||||
|
|
||||||
// 5일 이동평균선
|
// 5일 이동평균선
|
||||||
const sma5Series = (mainChart ).addLineSeries({
|
const sma5Series = mainChart.addLineSeries({
|
||||||
color: COLORS.ma5,
|
color: COLORS.ma5,
|
||||||
lineWidth: 1.5,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
sma5Series.setData(processedData.sma5);
|
sma5Series.setData((processedData as any).sma5);
|
||||||
|
|
||||||
// 60일 이동평균선
|
// 60일 이동평균선
|
||||||
const sma60Series = (mainChart ).addLineSeries({
|
const sma60Series = mainChart.addLineSeries({
|
||||||
color: COLORS.ma60,
|
color: COLORS.ma60,
|
||||||
lineWidth: 1.5,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
sma60Series.setData(processedData.sma60);
|
sma60Series.setData((processedData as any).sma60);
|
||||||
|
|
||||||
// === 2. 거래량 차트 ===
|
// === 2. 거래량 차트 ===
|
||||||
const volumeChart = createChart(volumeChartRef.current, {
|
const volumeChart = createChart(volumeChartRef.current, {
|
||||||
@@ -300,12 +301,12 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
width: containerWidth,
|
width: containerWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
const volumeSeries = (volumeChart ).addHistogramSeries({
|
const volumeSeries = volumeChart.addHistogramSeries({
|
||||||
priceFormat: {
|
priceFormat: {
|
||||||
type: 'volume',
|
type: 'volume',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
volumeSeries.setData(processedData.volumes);
|
volumeSeries.setData((processedData as any).volumes);
|
||||||
|
|
||||||
// === 3. RSI 차트 ===
|
// === 3. RSI 차트 ===
|
||||||
const rsiChart = createChart(rsiChartRef.current, {
|
const rsiChart = createChart(rsiChartRef.current, {
|
||||||
@@ -314,39 +315,39 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// RSI 라인
|
// RSI 라인
|
||||||
const rsiSeries = (rsiChart ).addLineSeries({
|
const rsiSeries = rsiChart.addLineSeries({
|
||||||
color: COLORS.rsi,
|
color: COLORS.rsi,
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
rsiSeries.setData(processedData.rsi.data);
|
rsiSeries.setData((processedData as any).rsi.data);
|
||||||
|
|
||||||
// RSI 기준선 (30, 50, 70)
|
// RSI 기준선 (30, 50, 70)
|
||||||
const rsiLine30Series = (rsiChart ).addLineSeries({
|
const rsiLine30Series = rsiChart.addLineSeries({
|
||||||
color: COLORS.border,
|
color: COLORS.border,
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: 2, // dashed
|
lineStyle: 2, // dashed
|
||||||
crosshairMarkerVisible: false,
|
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,
|
color: COLORS.border,
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: 2,
|
lineStyle: 2,
|
||||||
crosshairMarkerVisible: false,
|
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,
|
color: COLORS.border,
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: 2,
|
lineStyle: 2,
|
||||||
crosshairMarkerVisible: false,
|
crosshairMarkerVisible: false,
|
||||||
});
|
});
|
||||||
rsiLine70Series.setData(processedData.rsi.line70);
|
rsiLine70Series.setData((processedData as any).rsi.line70);
|
||||||
|
|
||||||
// RSI Y축 범위 고정 (0-100)
|
// RSI Y축 범위 고정 (0-100)
|
||||||
(rsiChart ).priceScale('right').applyOptions({
|
rsiChart.priceScale('right').applyOptions({
|
||||||
scaleMargins: {
|
scaleMargins: {
|
||||||
top: 0.1,
|
top: 0.1,
|
||||||
bottom: 0.1,
|
bottom: 0.1,
|
||||||
@@ -360,22 +361,22 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// MACD 히스토그램
|
// MACD 히스토그램
|
||||||
const macdHistogramSeries = (macdChart ).addHistogramSeries();
|
const macdHistogramSeries = macdChart.addHistogramSeries();
|
||||||
macdHistogramSeries.setData(processedData.macd.histogram);
|
macdHistogramSeries.setData((processedData as any).macd.histogram);
|
||||||
|
|
||||||
// MACD 라인
|
// MACD 라인
|
||||||
const macdLineSeries = (macdChart ).addLineSeries({
|
const macdLineSeries = macdChart.addLineSeries({
|
||||||
color: COLORS.macd,
|
color: COLORS.macd,
|
||||||
lineWidth: 1.5,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
macdLineSeries.setData(processedData.macd.line);
|
macdLineSeries.setData((processedData as any).macd.line);
|
||||||
|
|
||||||
// Signal 라인
|
// Signal 라인
|
||||||
const signalLineSeries = (macdChart ).addLineSeries({
|
const signalLineSeries = macdChart.addLineSeries({
|
||||||
color: COLORS.signal,
|
color: COLORS.signal,
|
||||||
lineWidth: 1.5,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
signalLineSeries.setData(processedData.macd.signal);
|
signalLineSeries.setData((processedData as any).macd.signal);
|
||||||
|
|
||||||
// === 시간축 동기화 ===
|
// === 시간축 동기화 ===
|
||||||
// 초기 visible range 설정 (모든 차트가 동일한 범위를 보이도록)
|
// 초기 visible range 설정 (모든 차트가 동일한 범위를 보이도록)
|
||||||
@@ -459,11 +460,12 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
|||||||
// Chart already disposed
|
// Chart already disposed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error | null;
|
||||||
console.error('❌ 차트 렌더링 중 오류 발생:', error);
|
console.error('❌ 차트 렌더링 중 오류 발생:', error);
|
||||||
console.error('오류 상세:', {
|
console.error('오류 상세:', {
|
||||||
message: error?.message || '알 수 없는 오류',
|
message: err?.message || '알 수 없는 오류',
|
||||||
stack: error?.stack,
|
stack: err?.stack,
|
||||||
processedData: processedData,
|
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 [connected, setConnected] = useState(false)
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const reconnectAttemptsRef = useRef(0)
|
const reconnectAttemptsRef = useRef(0)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
const socketRef = useRef<WebSocket | null>(null)
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
@@ -25,6 +26,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
|||||||
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
||||||
const onChartDataRef = useRef(options?.onChartData)
|
const onChartDataRef = useRef(options?.onChartData)
|
||||||
const baseDelay = 3000
|
const baseDelay = 3000
|
||||||
|
const lastPongReceivedRef = useRef<number>(Date.now())
|
||||||
|
const pongTimeoutMs = 10000 // 10초 이내에 pong을 받지 못하면 연결 끊김으로 간주
|
||||||
|
|
||||||
// options 변경 시 ref 동기화
|
// options 변경 시 ref 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,11 +45,25 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
|||||||
console.log('🔗 WebSocket connected')
|
console.log('🔗 WebSocket connected')
|
||||||
setConnected(true)
|
setConnected(true)
|
||||||
reconnectAttemptsRef.current = 0
|
reconnectAttemptsRef.current = 0
|
||||||
|
lastPongReceivedRef.current = Date.now()
|
||||||
|
|
||||||
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
|
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }))
|
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)
|
}, 30000)
|
||||||
}
|
}
|
||||||
@@ -54,7 +71,15 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data) as WebSocketMessage
|
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: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신)
|
// market_update: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신)
|
||||||
if (message.type === 'market_update') {
|
if (message.type === 'market_update') {
|
||||||
@@ -88,6 +113,11 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
|||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pongTimeoutRef.current) {
|
||||||
|
clearTimeout(pongTimeoutRef.current)
|
||||||
|
pongTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
|
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
const attempt = reconnectAttemptsRef.current
|
const attempt = reconnectAttemptsRef.current
|
||||||
@@ -118,6 +148,9 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
|||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
}
|
}
|
||||||
|
if (pongTimeoutRef.current) {
|
||||||
|
clearTimeout(pongTimeoutRef.current)
|
||||||
|
}
|
||||||
if (socketRef.current) {
|
if (socketRef.current) {
|
||||||
socketRef.current.close()
|
socketRef.current.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useWebSocket } from '../hooks/useWebSocket'
|
import { useWebSocket } from '../hooks/useWebSocket'
|
||||||
|
import { usePortfolioData } from '../hooks/usePortfolioData'
|
||||||
|
import { useMarketData } from '../hooks/useMarketData'
|
||||||
|
import { useTradeActions } from '../hooks/useTradeActions'
|
||||||
import { PortfolioSummary } from '../components/PortfolioSummary'
|
import { PortfolioSummary } from '../components/PortfolioSummary'
|
||||||
import PortfolioPanel from '../components/PortfolioPanel'
|
import PortfolioPanel from '../components/PortfolioPanel'
|
||||||
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
|
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
|
||||||
import OrderbookPanel from '../components/OrderbookPanel'
|
import OrderbookPanel from '../components/OrderbookPanel'
|
||||||
import RecommendationPanel from '../components/RecommendationPanel'
|
import RecommendationPanel from '../components/RecommendationPanel'
|
||||||
import JournalPanel from '../components/JournalPanel'
|
import JournalPanel from '../components/JournalPanel'
|
||||||
|
import { CandleData, TradeHistory } from '../types/trading'
|
||||||
import './Dashboard.css'
|
import './Dashboard.css'
|
||||||
|
|
||||||
// 마켓 운영 시간 체크 (한국장 + 미국장)
|
// 마켓 운영 시간 체크 (한국장 + 미국장)
|
||||||
@@ -29,47 +33,13 @@ const checkMarketOpen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
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 메시지로 로드)
|
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
|
||||||
const [chartData, setChartData] = useState<any[] | null>(null)
|
const [chartData, setChartData] = useState<CandleData[] | null>(null)
|
||||||
const [chartLoading, setChartLoading] = useState(false)
|
const [chartLoading, setChartLoading] = useState(false)
|
||||||
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
||||||
const selectedStockRef = useRef<string>('')
|
const selectedStockRef = useRef<string>('')
|
||||||
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
|
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 쿼리 활성화
|
// localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
|
||||||
const [selectedStock, setSelectedStock] = useState<string>(
|
const [selectedStock, setSelectedStock] = useState<string>(
|
||||||
() => localStorage.getItem('selectedStock') || ''
|
() => localStorage.getItem('selectedStock') || ''
|
||||||
@@ -80,7 +50,7 @@ const Dashboard = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const prevLatestTradeIdRef = useRef<number | null>(null)
|
const prevLatestTradeIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const isMarketOpen = checkMarketOpen();
|
const isMarketOpen = checkMarketOpen()
|
||||||
|
|
||||||
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
|
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
|
||||||
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
|
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
|
||||||
@@ -90,25 +60,94 @@ const Dashboard = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Queries
|
// Custom Hooks
|
||||||
// 원칙: 매매가 일어날 때만 호출되어야 하는 것은 polling 제거 + staleTime: Infinity
|
|
||||||
// 실시간성이 필요한 것만 refetchInterval 유지
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ✅ 보유종목 목록 + 요약 자산 - 단일 API 호출 (lock 경쟁 제거)
|
// Portfolio data hook
|
||||||
const { data: portfolioCombined, error: portfolioSummaryError } = useQuery({
|
const {
|
||||||
queryKey: ['portfolio-combined'],
|
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 () => {
|
queryFn: async () => {
|
||||||
const data = (await api.get('/portfolio/combined')).data;
|
if (!selectedStock) return null
|
||||||
return {
|
try {
|
||||||
holdings: (data.holdings as any[]).filter((item) => item.quantity > 0),
|
const response = await api.get(
|
||||||
summary: data.summary,
|
`/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,
|
staleTime: Infinity,
|
||||||
|
retry: 1,
|
||||||
})
|
})
|
||||||
const portfolio = portfolioCombined?.holdings
|
|
||||||
const portfolioSummary = portfolioCombined?.summary
|
|
||||||
|
|
||||||
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
||||||
useQuery({
|
useQuery({
|
||||||
@@ -120,26 +159,36 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
|
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
|
||||||
// trades_executed 발생 시: portfolio + trade-history 즉시 invalidate
|
// 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'],
|
queryKey: ['rule-based-recommendations'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/recommendations/rule-based', {
|
const response = await api.get('/recommendations/rule-based', {
|
||||||
params: {
|
params: {
|
||||||
realtime: false,
|
realtime: false,
|
||||||
auto_trade: true
|
auto_trade: true,
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data
|
||||||
|
|
||||||
// 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거)
|
// 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거)
|
||||||
if (data.trades_executed && data.trades_executed.length > 0) {
|
if (data.trades_executed && data.trades_executed.length > 0) {
|
||||||
console.log('✅ 자동매매 체결:', data.trades_executed.length, '건');
|
console.log(
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
'✅ 자동매매 체결:',
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
data.trades_executed.length,
|
||||||
|
'건'
|
||||||
|
)
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['trade-history'],
|
||||||
|
})
|
||||||
|
invalidatePortfolio()
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data
|
||||||
},
|
},
|
||||||
refetchInterval: isMarketOpen ? 120000 : 300000,
|
refetchInterval: isMarketOpen ? 120000 : 300000,
|
||||||
staleTime: isMarketOpen ? 110000 : 290000,
|
staleTime: isMarketOpen ? 110000 : 290000,
|
||||||
@@ -152,40 +201,31 @@ const Dashboard = () => {
|
|||||||
if (!selectedStock || !wsConnected) return
|
if (!selectedStock || !wsConnected) return
|
||||||
setChartLoading(true)
|
setChartLoading(true)
|
||||||
setChartData(null)
|
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])
|
}, [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초 (자동매매 체결 감지용)
|
// 거래 이력 - 30초 (자동매매 체결 감지용)
|
||||||
const { data: tradeHistory } = useQuery({
|
const { data: tradeHistory } = useQuery({
|
||||||
queryKey: ['trade-history'],
|
queryKey: ['trade-history'],
|
||||||
queryFn: async () => {
|
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();
|
const seen = new Set<number>()
|
||||||
return data.filter((trade: any) => {
|
return data
|
||||||
if (seen.has(trade.id)) return false;
|
.filter((trade: TradeHistory) => {
|
||||||
seen.add(trade.id);
|
if (seen.has(trade.id)) return false
|
||||||
return true;
|
seen.add(trade.id)
|
||||||
}).slice(0, 20);
|
return true
|
||||||
|
})
|
||||||
|
.slice(0, 20)
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchInterval: isMarketOpen ? 30000 : false,
|
refetchInterval: isMarketOpen ? 30000 : false,
|
||||||
@@ -195,7 +235,8 @@ const Dashboard = () => {
|
|||||||
// 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신)
|
// 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신)
|
||||||
const { data: journals } = useQuery({
|
const { data: journals } = useQuery({
|
||||||
queryKey: ['journals'],
|
queryKey: ['journals'],
|
||||||
queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data,
|
queryFn: async () =>
|
||||||
|
(await api.get('/journals', { params: { limit: 20 } })).data,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
})
|
})
|
||||||
@@ -205,16 +246,24 @@ const Dashboard = () => {
|
|||||||
// tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate
|
// tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tradeHistory || tradeHistory.length === 0) return;
|
if (!tradeHistory || tradeHistory.length === 0) return
|
||||||
const latestId = tradeHistory[0].id;
|
const latestId = tradeHistory[0].id
|
||||||
|
|
||||||
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
if (
|
||||||
const t = tradeHistory[0];
|
prevLatestTradeIdRef.current !== null &&
|
||||||
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
|
latestId > prevLatestTradeIdRef.current
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
) {
|
||||||
|
const t = tradeHistory[0]
|
||||||
|
console.log(
|
||||||
|
'✅ 새 매매 감지:',
|
||||||
|
t.stock_name,
|
||||||
|
t.trade_type,
|
||||||
|
'→ 포트폴리오 갱신'
|
||||||
|
)
|
||||||
|
invalidatePortfolio()
|
||||||
}
|
}
|
||||||
prevLatestTradeIdRef.current = latestId;
|
prevLatestTradeIdRef.current = latestId
|
||||||
}, [tradeHistory, queryClient])
|
}, [tradeHistory, invalidatePortfolio])
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// WebSocket 이벤트 처리
|
// WebSocket 이벤트 처리
|
||||||
@@ -222,49 +271,19 @@ const Dashboard = () => {
|
|||||||
// - journal_created: 매매일지 즉시 갱신
|
// - journal_created: 매매일지 즉시 갱신
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wsMessages.length === 0) return;
|
if (wsMessages.length === 0) return
|
||||||
const latest = wsMessages[wsMessages.length - 1];
|
const latest = wsMessages[wsMessages.length - 1]
|
||||||
|
|
||||||
if (latest.type === 'trade_executed') {
|
if (latest.type === 'trade_executed') {
|
||||||
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신');
|
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신')
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
invalidatePortfolio()
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latest.type === 'journal_created') {
|
if (latest.type === 'journal_created') {
|
||||||
queryClient.invalidateQueries({ queryKey: ['journals'] });
|
queryClient.invalidateQueries({ queryKey: ['journals'] })
|
||||||
}
|
}
|
||||||
}, [wsMessages, queryClient])
|
}, [wsMessages, invalidatePortfolio, 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])
|
|
||||||
|
|
||||||
// 실시간 주식평가금액/손익 — livePrices tick 기반 재계산
|
// 실시간 주식평가금액/손익 — livePrices tick 기반 재계산
|
||||||
// total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영
|
// total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영
|
||||||
@@ -273,28 +292,43 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
// live 현재가 기반 주식 평가금액
|
// live 현재가 기반 주식 평가금액
|
||||||
const liveValue = portfolio.reduce((sum, s) => {
|
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)
|
return sum + price * Number(s.quantity || 0)
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// 총 매입금액 (평균단가 × 수량)
|
// 총 매입금액 (평균단가 × 수량)
|
||||||
const totalInvestment = portfolio.reduce((sum, s) =>
|
const totalInvestment = portfolio.reduce(
|
||||||
sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0)
|
(sum, s) =>
|
||||||
|
sum + Number(s.average_price || 0) * Number(s.quantity || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
// 평가손익 = 주식 평가금액 - 매입금액
|
// 평가손익 = 주식 평가금액 - 매입금액
|
||||||
const liveProfitLoss = liveValue - totalInvestment
|
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(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
|
||||||
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
|
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
|
||||||
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
|
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
|
||||||
const backendStockValue = portfolioSummary.stock_value_from_api || 0
|
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 주식 변동분 반영
|
// 금일 손익도 live 주식 변동분 반영
|
||||||
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
|
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
|
||||||
const liveTodayPL = bfdyTotal > 0 ? liveTotal - bfdyTotal : (portfolioSummary.today_profit_loss || 0)
|
const liveTodayPL =
|
||||||
const liveTodayPLRate = bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 : (portfolioSummary.today_profit_loss_rate || 0)
|
bfdyTotal > 0
|
||||||
|
? liveTotal - bfdyTotal
|
||||||
|
: portfolioSummary.today_profit_loss || 0
|
||||||
|
const liveTodayPLRate =
|
||||||
|
bfdyTotal > 0
|
||||||
|
? (liveTodayPL / bfdyTotal) * 100
|
||||||
|
: portfolioSummary.today_profit_loss_rate || 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
@@ -311,8 +345,9 @@ const Dashboard = () => {
|
|||||||
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
|
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
|
||||||
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
|
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
|
||||||
const livePrice = livePrices[selectedStock]
|
const livePrice = livePrices[selectedStock]
|
||||||
const liveChartData = useMemo<any[] | null>(() => {
|
const liveChartData = useMemo<CandleData[] | null>(() => {
|
||||||
if (!chartData || chartData.length === 0 || !livePrice) return chartData ?? null
|
if (!chartData || chartData.length === 0 || !livePrice)
|
||||||
|
return chartData ?? null
|
||||||
const last = chartData[chartData.length - 1]
|
const last = chartData[chartData.length - 1]
|
||||||
return [
|
return [
|
||||||
...chartData.slice(0, -1),
|
...chartData.slice(0, -1),
|
||||||
@@ -329,7 +364,8 @@ const Dashboard = () => {
|
|||||||
const activeOrderbook = useMemo(() => {
|
const activeOrderbook = useMemo(() => {
|
||||||
if (!liveQuote) return orderbook ?? null
|
if (!liveQuote) return orderbook ?? null
|
||||||
return {
|
return {
|
||||||
current_price: livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
|
current_price:
|
||||||
|
livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
|
||||||
stock_name: orderbook?.stock_name,
|
stock_name: orderbook?.stock_name,
|
||||||
asks: [
|
asks: [
|
||||||
{ price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 },
|
{ price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 },
|
||||||
@@ -337,29 +373,29 @@ const Dashboard = () => {
|
|||||||
{ price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 },
|
{ price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 },
|
||||||
{ price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 },
|
{ price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 },
|
||||||
{ price: liveQuote.ask5, volume: liveQuote.ask_qty5 ?? 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: [
|
bids: [
|
||||||
{ price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 },
|
{ price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 },
|
||||||
{ price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 },
|
{ price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 },
|
||||||
{ price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 },
|
{ price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 },
|
||||||
{ price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 },
|
{ price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 },
|
||||||
{ price: liveQuote.bid5, volume: liveQuote.bid_qty5 ?? 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,
|
current_volume: 0,
|
||||||
}
|
}
|
||||||
}, [liveQuote, livePrice, orderbook])
|
}, [liveQuote, livePrice, orderbook])
|
||||||
|
|
||||||
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
||||||
const heldStockCodes = useMemo<Set<string>>(
|
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]
|
[portfolio]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pendingBuyStockCodes = useMemo<Set<string>>(
|
const pendingBuyStockCodes = useMemo<Set<string>>(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
(tradeHistory || [])
|
(tradeHistory || [])
|
||||||
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
.filter((t: TradeHistory) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
||||||
.map((t: any) => t.stock_code as string)
|
.map((t: TradeHistory) => t.stock_code as string)
|
||||||
),
|
),
|
||||||
[tradeHistory]
|
[tradeHistory]
|
||||||
)
|
)
|
||||||
@@ -374,24 +410,6 @@ const Dashboard = () => {
|
|||||||
)
|
)
|
||||||
}, [portfolio, ruleBasedRecommendations, selectedStock])
|
}, [portfolio, ruleBasedRecommendations, selectedStock])
|
||||||
|
|
||||||
// useCallback으로 안정화 → 패널 리렌더 방지
|
|
||||||
const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
|
|
||||||
await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
|
|
||||||
// 매도 즉시 포트폴리오 갱신
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-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({
|
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
|
||||||
queryKey: ['ai-analysis', selectedStock],
|
queryKey: ['ai-analysis', selectedStock],
|
||||||
@@ -439,12 +457,12 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
analysis += `\n🤖 분석 결과:\n`;
|
analysis += `\n🤖 분석 결과:\n`;
|
||||||
const recommendMap: any = {
|
const recommendMap: Record<string, string> = {
|
||||||
'BUY': '💚 매수',
|
'BUY': '💚 매수',
|
||||||
'SELL': '❤️ 매도',
|
'SELL': '❤️ 매도',
|
||||||
'HOLD': '💛 관망'
|
'HOLD': '💛 관망'
|
||||||
};
|
};
|
||||||
const trendMap: any = {
|
const trendMap: Record<string, string> = {
|
||||||
'UP': '📈 상승',
|
'UP': '📈 상승',
|
||||||
'DOWN': '📉 하락',
|
'DOWN': '📉 하락',
|
||||||
'SIDEWAYS': '➡️ 횡보'
|
'SIDEWAYS': '➡️ 횡보'
|
||||||
@@ -465,10 +483,11 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { analysis, data };
|
return { analysis, data };
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const err = error as any;
|
||||||
console.error('❌ AI 분석 실패:', error);
|
console.error('❌ AI 분석 실패:', error);
|
||||||
return {
|
return {
|
||||||
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${error.response?.data?.detail || error.message}`,
|
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${err.response?.data?.detail || err.message}`,
|
||||||
data: null
|
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