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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
128
frontend/src/pages/Login.tsx
Normal file
128
frontend/src/pages/Login.tsx
Normal 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
|
||||
Reference in New Issue
Block a user