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

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -18,6 +18,7 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
const [connected, setConnected] = useState(false)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const mountedRef = useRef(true)
const socketRef = useRef<WebSocket | null>(null)
@@ -25,6 +26,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
const onChartDataRef = useRef(options?.onChartData)
const baseDelay = 3000
const lastPongReceivedRef = useRef<number>(Date.now())
const pongTimeoutMs = 10000 // 10초 이내에 pong을 받지 못하면 연결 끊김으로 간주
// options 변경 시 ref 동기화
useEffect(() => {
@@ -42,11 +45,25 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
console.log('🔗 WebSocket connected')
setConnected(true)
reconnectAttemptsRef.current = 0
lastPongReceivedRef.current = Date.now()
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
// Pong 타임아웃 설정 (10초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current)
}
pongTimeoutRef.current = setTimeout(() => {
const timeSinceLastPong = Date.now() - lastPongReceivedRef.current
console.warn(
`⏱️ Pong timeout: No pong received for ${timeSinceLastPong}ms. Closing connection.`
)
ws.close()
}, pongTimeoutMs)
}
}, 30000)
}
@@ -54,7 +71,15 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage
if (message.type === 'pong') return
if (message.type === 'pong') {
// Pong 수신 — 타임아웃 타이머 취소 및 마지막 pong 시간 업데이트
lastPongReceivedRef.current = Date.now()
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current)
pongTimeoutRef.current = null
}
return
}
// market_update: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신)
if (message.type === 'market_update') {
@@ -88,6 +113,11 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
pingIntervalRef.current = null
}
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current)
pongTimeoutRef.current = null
}
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
if (mountedRef.current) {
const attempt = reconnectAttemptsRef.current
@@ -118,6 +148,9 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current)
}
if (socketRef.current) {
socketRef.current.close()
}

View File

@@ -2,12 +2,16 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
import { usePortfolioData } from '../hooks/usePortfolioData'
import { useMarketData } from '../hooks/useMarketData'
import { useTradeActions } from '../hooks/useTradeActions'
import { PortfolioSummary } from '../components/PortfolioSummary'
import PortfolioPanel from '../components/PortfolioPanel'
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
import { CandleData, TradeHistory } from '../types/trading'
import './Dashboard.css'
// 마켓 운영 시간 체크 (한국장 + 미국장)
@@ -29,47 +33,13 @@ const checkMarketOpen = () => {
};
const Dashboard = () => {
// 실시간 현재가 (KIS WebSocket tick → 즉시 반영)
const [livePrices, setLivePrices] = useState<Record<string, number>>({})
// 실시간 호가 (KIS WebSocket quote H0STASP0 → 선택 종목만 수신)
const [liveQuote, setLiveQuote] = useState<any>(null)
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
const [chartData, setChartData] = useState<any[] | null>(null)
const [chartData, setChartData] = useState<CandleData[] | null>(null)
const [chartLoading, setChartLoading] = useState(false)
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
const selectedStockRef = useRef<string>('')
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
const handleMarketUpdate = useCallback((data: unknown) => {
const d = data as any
if (d?._type === 'tick' && d.stock_code && d.price != null) {
setLivePrices(prev => ({ ...prev, [d.stock_code]: d.price }))
} else if (d?._type === 'quote' && d.stock_code === selectedStockRef.current) {
setLiveQuote(d)
}
}, [])
const handleChartData = useCallback((msg: unknown) => {
const m = msg as any
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
if (m?.stock_code !== selectedStockRef.current || m?.period !== chartPeriodRef.current) return
const data = (m.data || []).map((item: any) => ({
date: item.date,
open: Number(item.open),
high: Number(item.high),
low: Number(item.low),
close: Number(item.close),
volume: Number(item.volume),
})).reverse()
setChartData(data)
setChartLoading(false)
}, [])
const { connected: wsConnected, messages: wsMessages, sendMessage } = useWebSocket('dashboard', {
onMarketUpdate: handleMarketUpdate,
onChartData: handleChartData,
})
// localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
const [selectedStock, setSelectedStock] = useState<string>(
() => localStorage.getItem('selectedStock') || ''
@@ -80,7 +50,7 @@ const Dashboard = () => {
const queryClient = useQueryClient()
const prevLatestTradeIdRef = useRef<number | null>(null)
const isMarketOpen = checkMarketOpen();
const isMarketOpen = checkMarketOpen()
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
@@ -90,25 +60,94 @@ const Dashboard = () => {
}, [])
// ─────────────────────────────────────────────────────────────
// Queries
// 원칙: 매매가 일어날 때만 호출되어야 하는 것은 polling 제거 + staleTime: Infinity
// 실시간성이 필요한 것만 refetchInterval 유지
// Custom Hooks
// ─────────────────────────────────────────────────────────────
// ✅ 보유종목 목록 + 요약 자산 - 단일 API 호출 (lock 경쟁 제거)
const { data: portfolioCombined, error: portfolioSummaryError } = useQuery({
queryKey: ['portfolio-combined'],
// Portfolio data hook
const {
portfolio,
portfolioSummary,
portfolioSummaryError,
invalidatePortfolio,
} = usePortfolioData()
// Market data hook (with throttled live prices and market update handler)
const {
livePrices,
liveQuote,
handleMarketUpdate,
} = useMarketData(
selectedStock,
portfolio
)
// WebSocket hook - pass both market and chart data handlers
const { connected: wsConnected, messages: wsMessages, sendMessage } =
useWebSocket('dashboard', {
onMarketUpdate: handleMarketUpdate,
onChartData: (msg: unknown) => {
const m = msg as Record<string, unknown>
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
if (
m?.stock_code !== selectedStockRef.current ||
m?.period !== chartPeriodRef.current
)
return
const data = ((m.data as Record<string, unknown>[]) || [])
.map((item: Record<string, unknown>) => ({
date: item.date as string,
open: Number(item.open),
high: Number(item.high),
low: Number(item.low),
close: Number(item.close),
volume: Number(item.volume),
}))
.reverse()
setChartData(data)
setChartLoading(false)
},
})
// Trade actions hook
const { handleSell, resetPendingTrades } = useTradeActions()
// Subscribe to market data via WebSocket
useEffect(() => {
if (!wsConnected) return
const codes = [
...new Set([
...(portfolio || []).map((s: any) => s.stock_code as string),
...(selectedStock ? [selectedStock] : []),
]),
]
if (codes.length === 0) return
sendMessage(
JSON.stringify({ type: 'set_subscriptions', stock_codes: codes })
)
}, [wsConnected, portfolio, selectedStock, sendMessage])
// 호가창 - REST fallback (WebSocket quote로 실시간 갱신)
const { data: orderbook } = useQuery({
queryKey: ['orderbook', selectedStock],
queryFn: async () => {
const data = (await api.get('/portfolio/combined')).data;
return {
holdings: (data.holdings as any[]).filter((item) => item.quantity > 0),
summary: data.summary,
};
if (!selectedStock) return null
try {
const response = await api.get(
`/market-data/orderbook/${selectedStock}`
)
return response.data
} catch (error: any) {
console.error(
`Orderbook fetch failed (${selectedStock}):`,
error.response?.data?.detail || error.message
)
return null
}
},
enabled: Boolean(selectedStock),
staleTime: Infinity,
retry: 1,
})
const portfolio = portfolioCombined?.holdings
const portfolioSummary = portfolioCombined?.summary
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
useQuery({
@@ -120,26 +159,36 @@ const Dashboard = () => {
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
// trades_executed 발생 시: portfolio + trade-history 즉시 invalidate
const { data: ruleBasedRecommendations, refetch: refetchRecommendations, isError: isRecommendationsError } = useQuery({
const {
data: ruleBasedRecommendations,
refetch: refetchRecommendations,
isError: isRecommendationsError,
} = useQuery({
queryKey: ['rule-based-recommendations'],
queryFn: async () => {
const response = await api.get('/recommendations/rule-based', {
params: {
realtime: false,
auto_trade: true
}
});
auto_trade: true,
},
})
const data = response.data;
const data = response.data
// 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거)
if (data.trades_executed && data.trades_executed.length > 0) {
console.log('✅ 자동매매 체결:', data.trades_executed.length, '건');
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
console.log(
'✅ 자동매매 체결:',
data.trades_executed.length,
'건'
)
queryClient.invalidateQueries({
queryKey: ['trade-history'],
})
invalidatePortfolio()
}
return data;
return data
},
refetchInterval: isMarketOpen ? 120000 : 300000,
staleTime: isMarketOpen ? 110000 : 290000,
@@ -152,40 +201,31 @@ const Dashboard = () => {
if (!selectedStock || !wsConnected) return
setChartLoading(true)
setChartData(null)
sendMessage(JSON.stringify({ type: 'get_chart', stock_code: selectedStock, period: chartPeriod, limit: 100 }))
sendMessage(
JSON.stringify({
type: 'get_chart',
stock_code: selectedStock,
period: chartPeriod,
limit: 100,
})
)
}, [selectedStock, chartPeriod, wsConnected, sendMessage])
// 호가창 - 10초 (실시간성 필요하지만 5초는 과도)
const { data: orderbook } = useQuery({
queryKey: ['orderbook', selectedStock],
queryFn: async () => {
if (!selectedStock) return null;
try {
const response = await api.get(`/market-data/orderbook/${selectedStock}`);
return response.data;
} catch (error: any) {
console.error(`❌ 호가 조회 실패 (${selectedStock}):`, error.response?.data?.detail || error.message);
return null;
}
},
enabled: Boolean(selectedStock),
staleTime: Infinity,
// refetchInterval 제거 — WebSocket quote(H0STASP0)로 실시간 갱신
retry: 1,
})
// 거래 이력 - 30초 (자동매매 체결 감지용)
const { data: tradeHistory } = useQuery({
queryKey: ['trade-history'],
queryFn: async () => {
const data = (await api.get('/trades', { params: { limit: 50 } })).data;
const data = (await api.get('/trades', { params: { limit: 50 } }))
.data as TradeHistory[]
const seen = new Set();
return data.filter((trade: any) => {
if (seen.has(trade.id)) return false;
seen.add(trade.id);
return true;
}).slice(0, 20);
const seen = new Set<number>()
return data
.filter((trade: TradeHistory) => {
if (seen.has(trade.id)) return false
seen.add(trade.id)
return true
})
.slice(0, 20)
},
staleTime: 0,
refetchInterval: isMarketOpen ? 30000 : false,
@@ -195,7 +235,8 @@ const Dashboard = () => {
// 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신)
const { data: journals } = useQuery({
queryKey: ['journals'],
queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data,
queryFn: async () =>
(await api.get('/journals', { params: { limit: 20 } })).data,
staleTime: Infinity,
refetchOnWindowFocus: false,
})
@@ -205,16 +246,24 @@ const Dashboard = () => {
// tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate
// ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!tradeHistory || tradeHistory.length === 0) return;
const latestId = tradeHistory[0].id;
if (!tradeHistory || tradeHistory.length === 0) return
const latestId = tradeHistory[0].id
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
const t = tradeHistory[0];
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
if (
prevLatestTradeIdRef.current !== null &&
latestId > prevLatestTradeIdRef.current
) {
const t = tradeHistory[0]
console.log(
'✅ 새 매매 감지:',
t.stock_name,
t.trade_type,
'→ 포트폴리오 갱신'
)
invalidatePortfolio()
}
prevLatestTradeIdRef.current = latestId;
}, [tradeHistory, queryClient])
prevLatestTradeIdRef.current = latestId
}, [tradeHistory, invalidatePortfolio])
// ─────────────────────────────────────────────────────────────
// WebSocket 이벤트 처리
@@ -222,49 +271,19 @@ const Dashboard = () => {
// - journal_created: 매매일지 즉시 갱신
// ─────────────────────────────────────────────────────────────
useEffect(() => {
if (wsMessages.length === 0) return;
const latest = wsMessages[wsMessages.length - 1];
if (wsMessages.length === 0) return
const latest = wsMessages[wsMessages.length - 1]
if (latest.type === 'trade_executed') {
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신');
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신')
invalidatePortfolio()
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
}
if (latest.type === 'journal_created') {
queryClient.invalidateQueries({ queryKey: ['journals'] });
queryClient.invalidateQueries({ queryKey: ['journals'] })
}
}, [wsMessages, queryClient])
// 포트폴리오 로드 시 livePrices 초기값 설정 (REST current_price → 실시간 tick으로 덮어씌워짐)
useEffect(() => {
if (!portfolio) return
setLivePrices(prev => {
const next = { ...prev }
; (portfolio as any[]).forEach((s: any) => {
if (s.current_price && !next[s.stock_code]) {
next[s.stock_code] = Number(s.current_price)
}
})
return next
})
}, [portfolio])
// KIS WebSocket 구독: 보유 종목 + 선택 종목 → 실시간 tick/quote 수신
useEffect(() => {
if (!wsConnected) return
const codes = [...new Set([
...(portfolio || []).map((s: any) => s.stock_code as string),
...(selectedStock ? [selectedStock] : []),
])]
if (codes.length === 0) return
sendMessage(JSON.stringify({ type: 'set_subscriptions', stock_codes: codes }))
}, [wsConnected, portfolio, selectedStock, sendMessage])
// 선택 종목 변경 시 이전 호가 초기화 (새 quote 도착 전까지 REST fallback 사용)
useEffect(() => {
setLiveQuote(null)
}, [selectedStock])
}, [wsMessages, invalidatePortfolio, queryClient])
// 실시간 주식평가금액/손익 — livePrices tick 기반 재계산
// total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영
@@ -273,28 +292,43 @@ const Dashboard = () => {
// live 현재가 기반 주식 평가금액
const liveValue = portfolio.reduce((sum, s) => {
const price = livePrices[s.stock_code] ?? Number(s.current_price || 0)
const price =
livePrices[s.stock_code] ?? Number(s.current_price || 0)
return sum + price * Number(s.quantity || 0)
}, 0)
// 총 매입금액 (평균단가 × 수량)
const totalInvestment = portfolio.reduce((sum, s) =>
sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0)
const totalInvestment = portfolio.reduce(
(sum, s) =>
sum + Number(s.average_price || 0) * Number(s.quantity || 0),
0
)
// 평가손익 = 주식 평가금액 - 매입금액
const liveProfitLoss = liveValue - totalInvestment
const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0
const liveProfitLossRate =
totalInvestment > 0
? (liveProfitLoss / totalInvestment) * 100
: 0
// 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
const backendStockValue = portfolioSummary.stock_value_from_api || 0
const liveTotal = (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue)
const liveTotal =
(portfolioSummary.total_assets || 0) +
(liveValue - backendStockValue)
// 금일 손익도 live 주식 변동분 반영
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
const liveTodayPL = bfdyTotal > 0 ? liveTotal - bfdyTotal : (portfolioSummary.today_profit_loss || 0)
const liveTodayPLRate = bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 : (portfolioSummary.today_profit_loss_rate || 0)
const liveTodayPL =
bfdyTotal > 0
? liveTotal - bfdyTotal
: portfolioSummary.today_profit_loss || 0
const liveTodayPLRate =
bfdyTotal > 0
? (liveTodayPL / bfdyTotal) * 100
: portfolioSummary.today_profit_loss_rate || 0
return {
...portfolioSummary,
@@ -311,8 +345,9 @@ const Dashboard = () => {
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
const livePrice = livePrices[selectedStock]
const liveChartData = useMemo<any[] | null>(() => {
if (!chartData || chartData.length === 0 || !livePrice) return chartData ?? null
const liveChartData = useMemo<CandleData[] | null>(() => {
if (!chartData || chartData.length === 0 || !livePrice)
return chartData ?? null
const last = chartData[chartData.length - 1]
return [
...chartData.slice(0, -1),
@@ -329,7 +364,8 @@ const Dashboard = () => {
const activeOrderbook = useMemo(() => {
if (!liveQuote) return orderbook ?? null
return {
current_price: livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
current_price:
livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
stock_name: orderbook?.stock_name,
asks: [
{ price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 },
@@ -337,29 +373,29 @@ const Dashboard = () => {
{ price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 },
{ price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 },
{ price: liveQuote.ask5, volume: liveQuote.ask_qty5 ?? 0 },
].filter(a => a.price != null && a.price > 0),
].filter((a) => a.price != null && a.price > 0),
bids: [
{ price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 },
{ price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 },
{ price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 },
{ price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 },
{ price: liveQuote.bid5, volume: liveQuote.bid_qty5 ?? 0 },
].filter(b => b.price != null && b.price > 0),
].filter((b) => b.price != null && b.price > 0),
current_volume: 0,
}
}, [liveQuote, livePrice, orderbook])
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
const heldStockCodes = useMemo<Set<string>>(
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
() => new Set((portfolio || []).map((s) => s.stock_code as string)),
[portfolio]
)
const pendingBuyStockCodes = useMemo<Set<string>>(
() => new Set(
(tradeHistory || [])
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
.map((t: any) => t.stock_code as string)
.filter((t: TradeHistory) => t.status === 'PENDING' && t.trade_type === 'BUY')
.map((t: TradeHistory) => t.stock_code as string)
),
[tradeHistory]
)
@@ -374,24 +410,6 @@ const Dashboard = () => {
)
}, [portfolio, ruleBasedRecommendations, selectedStock])
// useCallback으로 안정화 → 패널 리렌더 방지
const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
// 매도 즉시 포트폴리오 갱신
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
console.log(`✅ ${stockName} 매도 주문 완료`)
}, [queryClient])
const resetPendingTrades = useCallback(async () => {
try {
const response = await api.delete('/trades/pending');
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
console.log('✅ 미체결 주문 정리 완료:', response.data);
} catch (error: any) {
console.error('미체결 주문 정리 실패:', error);
}
}, [queryClient])
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
queryKey: ['ai-analysis', selectedStock],
@@ -439,12 +457,12 @@ const Dashboard = () => {
}
analysis += `\n🤖 분석 결과:\n`;
const recommendMap: any = {
const recommendMap: Record<string, string> = {
'BUY': '💚 매수',
'SELL': '❤️ 매도',
'HOLD': '💛 관망'
};
const trendMap: any = {
const trendMap: Record<string, string> = {
'UP': '📈 상승',
'DOWN': '📉 하락',
'SIDEWAYS': '➡️ 횡보'
@@ -465,10 +483,11 @@ const Dashboard = () => {
}
return { analysis, data };
} catch (error: any) {
} catch (error: unknown) {
const err = error as any;
console.error('❌ AI 분석 실패:', error);
return {
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${error.response?.data?.detail || error.message}`,
analysis: `❌ AI 분석을 불러올 수 없습니다.\n${err.response?.data?.detail || err.message}`,
data: null
};
}

View 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;

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