refactor: 프론트엔드 TypeScript 타입 안전성 및 커스텀 훅 분리

- types/trading.ts: 중앙 타입 정의 (CandleData, Position, Trade 등)
- useMarketData.ts: 실시간 시세 처리 + 500ms 쓰로틀링
- usePortfolioData.ts: React Query 기반 포트폴리오 데이터 훅
- useTradeActions.ts: 매매 실행 액션 훅
- useWebSocket.ts: pong timeout 10초 감지 (좀비 연결 방지)
- TradingChart.tsx: @ts-nocheck 제거, CandleData 타입 적용
- PortfolioPanel/RecommendationPanel: any → 구체적 타입 교체
- Dashboard.tsx: 상태 관리 로직을 커스텀 훅으로 이전

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:26:27 +09:00
parent a477072bdc
commit dc9bc114da
10 changed files with 736 additions and 244 deletions

View File

@@ -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}`)
}
}

View File

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

View File

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