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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user