fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리

## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -1,7 +1,16 @@
/* Custom Scrollbar for Dashboard containers */
/**
* Dashboard.css — 대시보드 페이지 전용 스타일
*
* 역할:
* - 커스텀 스크롤바 스타일 정의 (다크 테마에 맞춘 3px 슬림 스크롤바)
* - .custom-scrollbar 클래스를 가진 요소에 적용됨
* - 주로 포트폴리오 패널, 추천 종목 패널, 매매일지 패널 등 스크롤 영역에 사용
*/
/* Custom Scrollbar — Professional Dark */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 3px;
height: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@@ -9,10 +18,10 @@
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #374151; /* gray-700 */
border-radius: 3px;
background: #1e3050;
border-radius: 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4b5563; /* gray-600 */
background: #2b4570;
}

View File

@@ -1,3 +1,24 @@
/**
* Dashboard.tsx — 메인 대시보드 페이지 컴포넌트
*
* 역할:
* - 전체 트레이딩 대시보드의 상태 관리 및 레이아웃 구성
* - WebSocket을 통한 실시간 시장 데이터 및 차트 데이터 수신
* - 포트폴리오, 차트, 호가, 추천종목, 매매내역, AI분석 패널 통합
*
* 레이아웃 (12컬럼 그리드):
* [2col] 보유종목 | [7col] 차트+AI분석+호가 | [3col] 추천종목+매매내역
*
* 데이터 흐름:
* - WebSocket: 실시간 tick/quote → useMarketData, 차트 데이터 → processChartData
* - react-query: 포트폴리오(30s), 호가(10s), 추천종목(2min), 매매내역(30s), 일지(이벤트)
* - 매매 감지: tradeHistory 폴링에서 새 거래 ID 감지 → 포트폴리오 즉시 갱신
*
* 성능 최적화:
* - 각 패널은 React.memo로 불필요한 리렌더 방지
* - ref 기반 stale closure 방지 (selectedStockRef, chartPeriodRef 등)
* - useCallback/useMemo로 콜백 및 파생 데이터 안정화
*/
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
@@ -11,31 +32,64 @@ 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 { CandleData, Position, TradeHistory } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import './Dashboard.css'
// 마켓 운영 시간 체크 (한국장 + 미국장)
/**
* 마켓 운영 시간 체크 (한국장 + 미국장)
*
* 한국장: 09:00~15:30 KST (540분~930분)
* 미국장: 22:30~06:00 KST (1350분~다음날 360분)
*
* 장 운영 여부에 따라 API 폴링 간격이 달라짐:
* - 장중: 빈번한 폴링 (호가 10초, 매매내역 30초 등)
* - 장외: 폴링 중단 또는 간격 확대
*
* @returns 한국장 또는 미국장이 운영 중이면 true
*/
const checkMarketOpen = () => {
const now = new Date();
const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" }));
const day = kstTime.getDay();
const day = kstTime.getDay(); // 0: 일, 6: 토
const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes();
// 일요일 또는 토요일 07:00 이후 → 장 종료
if (day === 0 || (day === 6 && currentMinutes >= 420)) return false;
// 한국장: 09:00(540분) ~ 15:30(930분)
const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 930;
// 미국장: 22:30(1350분) ~ 익일 06:00(360분) KST 기준
const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360;
// 금요일 22:30 이후 → 미국장 개장
if (day === 5 && currentMinutes >= 1350) return true;
// 토요일 06:00 이전 → 미국장 진행 중
if (day === 6 && currentMinutes <= 360) return true;
return isKoreaOpen || isUSOpen;
};
/** 마켓 필터 타입 (전체/국내/미국) */
type MarketFilter = 'ALL' | 'KR' | 'US'
/**
* Dashboard — 메인 대시보드 컴포넌트
*
* 주요 상태:
* - marketFilter: 마켓 필터 (ALL/KR/US)
* - selectedStock: 선택된 종목 코드 (localStorage 저장)
* - chartData/chartLoading: 차트 데이터 및 로딩 상태
* - chartPeriod: 차트 기간 (D/W/M)
*/
const Dashboard = () => {
const [marketFilter, setMarketFilter] = useState<MarketFilter>('ALL')
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
const [chartData, setChartData] = useState<CandleData[] | null>(null)
const chartDataRef = useRef<CandleData[] | null>(null)
const [chartLoading, setChartLoading] = useState(false)
const chartLoadingRef = useRef(false)
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
const selectedStockRef = useRef<string>('')
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
@@ -81,50 +135,110 @@ const Dashboard = () => {
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)
},
// chart_data 메시지 처리 함수 (wsMessages useEffect에서 호출)
const processChartData = useCallback((m: Record<string, unknown>) => {
console.log('📊 processChartData called', {
msgStock: m?.stock_code,
msgPeriod: m?.period,
refStock: selectedStockRef.current,
refPeriod: chartPeriodRef.current,
})
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
if (
m?.stock_code !== selectedStockRef.current ||
m?.period !== chartPeriodRef.current
) {
console.log('📊 processChartData SKIP — stock/period mismatch')
return
}
const rawData = (m.data as Record<string, unknown>[]) || []
if (rawData.length === 0) {
chartDataRef.current = null
setChartData(null)
chartLoadingRef.current = false
setChartLoading(false)
return
}
const data = rawData.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),
}))
// 동일 데이터면 setState 건너뛰기 (차트 재생성 방지)
const prev = chartDataRef.current
const isSame = prev && prev.length === data.length &&
prev.length > 0 &&
prev[0].date === data[0].date &&
prev[prev.length - 1].date === data[data.length - 1].date &&
prev[prev.length - 1].close === data[data.length - 1].close
if (!isSame) {
chartDataRef.current = data
setChartData(data)
}
if (chartLoadingRef.current) {
chartLoadingRef.current = false
setChartLoading(false)
}
}, [])
// WebSocket hook - market_update는 콜백, chart_data는 messages로 처리
// 탭마다 고유 client_id 부여 (동일 ID로 2개 연결 시 서버에서 이전 연결 덮어쓰기 → 끊김 방지)
const [wsClientId] = useState(() => `dashboard_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`)
const { connected: wsConnected, messages: wsMessages, sendMessage } =
useWebSocket(wsClientId, {
onMarketUpdate: handleMarketUpdate,
})
// 차트 요청 헬퍼용 ref — useWebSocket 호출 직후 정의 (사용 useEffect보다 앞에 위치해야 함)
const wsConnectedRef = useRef(wsConnected)
wsConnectedRef.current = wsConnected
const sendMessageRef = useRef(sendMessage)
sendMessageRef.current = sendMessage
// chart_data: CustomEvent 리스너 (React 상태 우회 — StrictMode 타이밍 이슈 방지)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail
console.log('📨 CustomEvent chart_data 수신', detail?.stock_code)
processChartData(detail as Record<string, unknown>)
}
window.addEventListener('ws-chart-data', handler)
return () => window.removeEventListener('ws-chart-data', handler)
}, [processChartData])
// Trade actions hook
const { handleSell, resetPendingTrades } = useTradeActions()
// Subscribe to market data via WebSocket
// reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독
const prevSubCodesRef = useRef<string>('')
const prevWsConnectedRef = useRef(false)
useEffect(() => {
if (!wsConnected) return
if (!wsConnected) {
prevWsConnectedRef.current = false
return
}
const isReconnect = !prevWsConnectedRef.current
prevWsConnectedRef.current = true
const codes = [
...new Set([
...(portfolio || []).map((s: any) => s.stock_code as string),
...(selectedStock ? [selectedStock] : []),
]),
]
].sort()
if (codes.length === 0) return
sendMessage(
const key = codes.join(',')
// reconnect 시 강제 재전송, 그 외엔 변경 시에만
if (!isReconnect && key === prevSubCodesRef.current) return
prevSubCodesRef.current = key
sendMessageRef.current(
JSON.stringify({ type: 'set_subscriptions', stock_codes: codes })
)
}, [wsConnected, portfolio, selectedStock, sendMessage])
}, [wsConnected, portfolio, selectedStock])
// 호가창 - REST fallback (WebSocket quote로 실시간 갱신)
const { data: orderbook } = useQuery({
@@ -145,18 +259,15 @@ const Dashboard = () => {
}
},
enabled: Boolean(selectedStock),
staleTime: Infinity,
staleTime: isMarketOpen ? 5000 : 60000,
refetchInterval: (query) => {
// 500 에러 시 폴링 간격을 60초로 늘려 서버 부하 방지
if (query.state.error) return 60000
return isMarketOpen ? 10000 : false
},
retry: 1,
})
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
useQuery({
queryKey: ['mcp-status'],
queryFn: async () => (await api.get('/mcp/status')).data,
refetchInterval: isMarketOpen ? 30000 : false,
staleTime: 20000,
})
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
// trades_executed 발생 시: portfolio + trade-history 즉시 invalidate
const {
@@ -195,21 +306,49 @@ const Dashboard = () => {
refetchOnWindowFocus: false,
})
// 차트 데이터 — WebSocket get_chart 요청으로 로드 (REST useQuery 대체)
// selectedStock / chartPeriod / wsConnected 변경 시 자동 재요청
useEffect(() => {
if (!selectedStock || !wsConnected) return
setChartLoading(true)
setChartData(null)
sendMessage(
// 차트 요청 헬퍼 — ref 기반으로 의존성 최소화 (리렌더 방지)
const requestChartRef = useRef((stock: string, period: string): boolean => {
console.log('📤 requestChart', { stock, period, wsConnected: wsConnectedRef.current })
if (!stock || !wsConnectedRef.current) return false
sendMessageRef.current(
JSON.stringify({
type: 'get_chart',
stock_code: selectedStock,
period: chartPeriod,
limit: 100,
stock_code: stock,
period: period,
limit: 200,
})
)
}, [selectedStock, chartPeriod, wsConnected, sendMessage])
console.log('📤 requestChart SENT')
return true
})
// ref 내용은 항상 최신 — useCallback 불필요 (의존성 변경에 의한 리렌더 차단)
// 차트 데이터 — selectedStock / chartPeriod 변경 시 요청
useEffect(() => {
if (!selectedStock) {
chartDataRef.current = null
setChartData(null)
chartLoadingRef.current = false
setChartLoading(false)
return
}
const sent = requestChartRef.current(selectedStock, chartPeriod)
if (sent) {
chartLoadingRef.current = true
setChartLoading(true)
}
}, [selectedStock, chartPeriod])
// WebSocket (재)연결 시 — 차트 데이터 요청
useEffect(() => {
if (!selectedStock || !wsConnected) return
// 이미 데이터가 있으면 로딩 표시 안 함 (조용히 갱신)
if (!chartDataRef.current) {
chartLoadingRef.current = true
setChartLoading(true)
}
requestChartRef.current(selectedStock, chartPeriod)
}, [wsConnected]) // eslint-disable-line react-hooks/exhaustive-deps
// 거래 이력 - 30초 (자동매매 체결 감지용)
const { data: tradeHistory } = useQuery({
@@ -269,19 +408,25 @@ const Dashboard = () => {
// WebSocket 이벤트 처리
// - trade_executed: 체결 즉시 포트폴리오 갱신 (백엔드에서 broadcast 필요)
// - journal_created: 매매일지 즉시 갱신
// - processedCountRef로 미처리 메시지를 모두 순회 (동시 이벤트 누락 방지)
// ─────────────────────────────────────────────────────────────
const processedCountRef = useRef(0)
useEffect(() => {
if (wsMessages.length === 0) return
const latest = wsMessages[wsMessages.length - 1]
if (wsMessages.length <= processedCountRef.current) return
if (latest.type === 'trade_executed') {
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신')
invalidatePortfolio()
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
}
const unprocessed = wsMessages.slice(processedCountRef.current)
processedCountRef.current = wsMessages.length
if (latest.type === 'journal_created') {
queryClient.invalidateQueries({ queryKey: ['journals'] })
for (const msg of unprocessed) {
if (msg.type === 'trade_executed') {
console.log('WebSocket trade_executed -> portfolio refresh')
invalidatePortfolio()
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
}
if (msg.type === 'journal_created') {
queryClient.invalidateQueries({ queryKey: ['journals'] })
}
}
}, [wsMessages, invalidatePortfolio, queryClient])
@@ -297,12 +442,13 @@ const Dashboard = () => {
return sum + price * Number(s.quantity || 0)
}, 0)
// 총 매입금액 (평균단가 × 수량)
const totalInvestment = portfolio.reduce(
// 총 매입금액 (평균단가 × 수량) — average_price null 시 백엔드 summary 값 사용
const computedInvestment = portfolio.reduce(
(sum, s) =>
sum + Number(s.average_price || 0) * Number(s.quantity || 0),
0
)
const totalInvestment = computedInvestment || Number(portfolioSummary.total_investment || 0)
// 평가손익 = 주식 평가금액 - 매입금액
const liveProfitLoss = liveValue - totalInvestment
@@ -311,13 +457,13 @@ const Dashboard = () => {
? (liveProfitLoss / totalInvestment) * 100
: 0
// 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
// 총 자산 = KIS tot_evlu_amt 기준으로 실시간 주식평가 변동분 보정
// stock_value_from_api(scts_evlu_amt)가 0이면 예수금+실시간주식평가로 단순 계산
const backendStockValue = portfolioSummary.stock_value_from_api || 0
const liveTotal =
(portfolioSummary.total_assets || 0) +
(liveValue - backendStockValue)
backendStockValue > 0
? (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue)
: Number(portfolioSummary.available_cash || 0) + liveValue
// 금일 손익도 live 주식 변동분 반영
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
@@ -342,23 +488,46 @@ const Dashboard = () => {
}
}, [portfolioSummary, portfolio, livePrices])
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
// 최근 거래일 매매 통계 — tradeHistory 기반 실시간 집계
// 오늘 체결 거래가 없으면 가장 최근 체결일 기준으로 집계
// API 필드: order_type(BUY/SELL), total_amount(체결금액), executed_price, executed_quantity
const todayTradeStats = useMemo(() => {
if (!tradeHistory) return null
const DONE_STATUSES = ['EXECUTED', 'COMPLETED', 'FILLED']
const executedTrades = tradeHistory.filter(
(t: TradeHistory) => DONE_STATUSES.includes(t.status)
)
if (executedTrades.length === 0) return { buyAmount: 0, sellAmount: 0, realizedPL: 0, count: 0 }
// 오늘 거래 우선, 없으면 가장 최근 체결일 사용
const todayStr = new Date().toDateString()
const getDateStr = (t: TradeHistory) =>
new Date(t.executed_at || t.created_at || '').toDateString()
const hasTodayTrades = executedTrades.some((t: TradeHistory) => getDateStr(t) === todayStr)
const targetDateStr = hasTodayTrades
? todayStr
: getDateStr(executedTrades[0]) // 최신순 정렬이므로 첫 번째가 최근
const todayTrades = executedTrades.filter(
(t: TradeHistory) => getDateStr(t) === targetDateStr
)
const toAmount = (t: TradeHistory) =>
Number(t.total_amount || 0) ||
Number(t.executed_price || 0) * Number(t.executed_quantity || 0)
const orderType = (t: TradeHistory) => t.order_type || t.trade_type
const buyAmount = todayTrades
.filter((t: TradeHistory) => orderType(t) === 'BUY')
.reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0)
const sellAmount = todayTrades
.filter((t: TradeHistory) => orderType(t) === 'SELL')
.reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0)
const realizedPL = todayTrades
.filter((t: TradeHistory) => orderType(t) === 'SELL')
.reduce((sum: number, t: TradeHistory) => sum + Number(t.profit_loss || 0), 0)
const isToday = targetDateStr === todayStr
return { buyAmount, sellAmount, realizedPL, count: todayTrades.length, isToday, dateStr: targetDateStr }
}, [tradeHistory])
const livePrice = livePrices[selectedStock]
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),
{
...last,
close: livePrice,
high: Math.max(last.high, livePrice),
low: Math.min(last.low, livePrice),
},
]
}, [chartData, livePrice])
// 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백
const activeOrderbook = useMemo(() => {
@@ -385,6 +554,15 @@ const Dashboard = () => {
}
}, [liveQuote, livePrice, orderbook])
// 마켓 필터 적용된 포트폴리오
const filteredPortfolio = useMemo(() => {
if (!portfolio) return portfolio
if (marketFilter === 'ALL') return portfolio
return portfolio.filter((s: Position) =>
marketFilter === 'US' ? isUsStock(s.stock_code) : !isUsStock(s.stock_code)
)
}, [portfolio, marketFilter])
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
const heldStockCodes = useMemo<Set<string>>(
() => new Set((portfolio || []).map((s) => s.stock_code as string)),
@@ -429,27 +607,27 @@ const Dashboard = () => {
const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock
let analysis = `📈 ${displayName} (${data.stock_code})\n\n`;
analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`;
analysis += `📊 고가/저가: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}원\n`;
analysis += `📦 거래량: ${(data.volume / 1000).toFixed(0)}K주\n`;
analysis += `💰 현재가: ${data.current_price?.toLocaleString() ?? 'N/A'}원\n`;
analysis += `📊 고가/저가: ${data.high_price?.toLocaleString() ?? 'N/A'} / ${data.low_price?.toLocaleString() ?? 'N/A'}원\n`;
analysis += `📦 거래량: ${data.volume != null ? (data.volume / 1000).toFixed(0) : 'N/A'}K주\n`;
analysis += `\n📊 기술적 지표:\n`;
const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined);
if (hasIndicators) {
if (data.indicators.rsi !== null && data.indicators.rsi !== undefined) {
if (data.indicators.rsi != null) {
analysis += ` RSI(14): ${data.indicators.rsi.toFixed(1)}\n`;
}
if (data.indicators.ma5 !== null) {
if (data.indicators.ma5 != null) {
analysis += ` 5일 이평: ${data.indicators.ma5.toFixed(0)}원\n`;
}
if (data.indicators.ma20 !== null) {
if (data.indicators.ma20 != null) {
analysis += ` 20일 이평: ${data.indicators.ma20.toFixed(0)}원\n`;
}
if (data.indicators.ma60 !== null) {
if (data.indicators.ma60 != null) {
analysis += ` 60일 이평: ${data.indicators.ma60.toFixed(0)}원\n`;
}
if (data.indicators.stochastic_k !== null) {
if (data.indicators.stochastic_k != null) {
analysis += ` 스토캐스틱 K: ${data.indicators.stochastic_k.toFixed(1)}\n`;
}
} else {
@@ -468,7 +646,7 @@ const Dashboard = () => {
'SIDEWAYS': '➡️ 횡보'
};
analysis += ` 추천: ${recommendMap[data.recommendation] || data.recommendation}\n`;
analysis += ` 신뢰도: ${(data.confidence * 100).toFixed(0)}%\n`;
analysis += ` 신뢰도: ${data.confidence != null ? (data.confidence * 100).toFixed(0) : 'N/A'}%\n`;
analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`;
const skipMessages = [
@@ -520,19 +698,36 @@ const Dashboard = () => {
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
<div className="bg-[#0f1115] text-gray-100 font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
<div className="bg-[#080d16] text-[#c5d5e8] font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-4 overflow-hidden">
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-3 overflow-hidden">
<div className="flex-shrink-0">
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError} />
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-0 bg-[#0c1522] border border-[#152035] rounded-sm overflow-hidden">
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
<button
key={value}
onClick={() => setMarketFilter(value as MarketFilter)}
className={`px-3 py-1.5 text-[10px] font-mono transition-colors ${
marketFilter === value
? 'bg-[#1a3050] text-[#5b9aff]'
: 'text-[#4a6580] hover:text-[#8faabf]'
}`}
>
{label}
</button>
))}
</div>
</div>
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError?.message ?? null} todayTradeStats={todayTradeStats} />
</div>
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden">
<div className="col-span-2 flex flex-col gap-3 h-full overflow-hidden">
<PortfolioPanel
portfolio={portfolio}
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
@@ -542,28 +737,29 @@ const Dashboard = () => {
</div>
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
<div className="col-span-7 flex flex-col h-full gap-3 overflow-hidden">
{/* 차트 섹션 */}
<ChartPanel
chartData={liveChartData}
chartData={chartData}
chartLoading={chartLoading}
selectedStock={selectedStock}
chartPeriod={chartPeriod}
onPeriodChange={setChartPeriod}
stockName={stockName}
livePrice={livePrice}
/>
{/* 하단: AI 투자 분석 & 호가창
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
<div className="flex-1 grid grid-cols-2 gap-3 min-h-0">
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
<OrderbookPanel orderbook={activeOrderbook} />
</div>
</div>
{/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
<div className="col-span-3 flex flex-col gap-3 h-full overflow-hidden">
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
@@ -577,6 +773,7 @@ const Dashboard = () => {
/>
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
/>
</div>

View File

@@ -0,0 +1,128 @@
/**
* Login.tsx — 로그인 페이지 컴포넌트
*
* 역할:
* - API 키(SECRET_KEY) 입력을 통한 사용자 인증 처리
* - 백엔드 portfolio API로 키 유효성 검증 후 App 컴포넌트에 인증 콜백 전달
* - 인증 실패 시 에러 메시지 표시 (401/403 또는 네트워크 오류)
*
* Props:
* - onLogin: 인증 성공 시 API 키를 전달받는 콜백 (App에서 localStorage 저장)
*/
import { useState, FormEvent } from 'react'
import { Activity } from 'lucide-react'
/** Login 컴포넌트 Props 정의 */
interface LoginProps {
/** 인증 성공 시 호출되는 콜백 — API 키 문자열 전달 */
onLogin: (apiKey: string) => void
}
/**
* Login — API 키 인증 폼 컴포넌트
*
* 상태(State):
* - apiKey: 사용자 입력 API 키
* - error: 인증 실패 에러 메시지
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
*/
const Login = ({ onLogin }: LoginProps) => {
const [apiKey, setApiKey] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
/**
* 폼 제출 핸들러 — API 키 유효성 검증
*
* 검증 흐름:
* 1. 빈 입력 체크
* 2. /api/v1/portfolio/combined 엔드포인트로 API 키 검증 요청
* 3. 401/403 응답 시 에러 표시, 성공 시 onLogin 콜백 호출
*/
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!apiKey.trim()) {
setError('API 키를 입력하세요.')
return
}
setLoading(true)
setError('')
try {
// API 키 검증: portfolio 엔드포인트로 테스트
const res = await fetch('/api/v1/portfolio/combined', {
headers: { 'X-API-Key': apiKey.trim() },
})
if (res.status === 401 || res.status === 403) {
setError('유효하지 않은 API 키입니다.')
setLoading(false)
return
}
if (!res.ok) {
setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`)
setLoading(false)
return
}
// 검증 성공 — 상위 컴포넌트에 API 키 전달
onLogin(apiKey.trim())
} catch {
setError('서버 연결에 실패했습니다.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
<div className="w-full max-w-sm p-8">
<div className="flex flex-col items-center gap-4 mb-8">
<div className="bg-blue-600 p-3 rounded-xl shadow-lg shadow-blue-500/20">
<Activity className="text-white" size={28} />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
AutoStock
</h1>
<p className="text-xs text-gray-500 font-mono mt-1">GEN 3 ALGO</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="SECRET_KEY 입력"
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
autoFocus
/>
</div>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-medium rounded-lg transition-colors"
>
{loading ? '확인 중...' : '로그인'}
</button>
</form>
<p className="text-xs text-gray-600 text-center mt-6">
.env 파일의 SECRET_KEY 값을 입력하세요
</p>
</div>
</div>
)
}
export default Login