diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index f25703f..2556d9f 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -62,6 +62,51 @@ class PortfolioCache: def mark_changed(self): self.change_flag = True + + def apply_buy(self, stock_code: str, stock_name: str, quantity: int, price: float): + """매수 체결 즉시 캐시 반영 — KIS 재동기화 전까지 summary 수치 정확하게 유지""" + if self.summary is None: + self.mark_changed() + return + cost = quantity * price + # 예수금 차감 + self.summary["available_cash"] = max(0.0, float(self.summary.get("available_cash", 0)) - cost) + # 주식 평가금액 증가 + self.summary["total_value"] = float(self.summary.get("total_value", 0)) + cost + # 매입금액 증가 + self.summary["total_investment"] = float(self.summary.get("total_investment", 0)) + cost + # 총 자산 = 예수금 + 주식평가 (재계산) + self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"] + # 평가손익 재계산 + inv = self.summary["total_investment"] + val = self.summary["total_value"] + self.summary["total_profit_loss"] = val - inv + self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0 + self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0]) + self.mark_changed() + + def apply_sell(self, stock_code: str, quantity: int, price: float, avg_price: float): + """매도 체결 즉시 캐시 반영""" + if self.summary is None: + self.mark_changed() + return + proceeds = quantity * price + cost = quantity * avg_price + # 예수금 증가 + self.summary["available_cash"] = float(self.summary.get("available_cash", 0)) + proceeds + # 주식 평가금액 감소 + self.summary["total_value"] = max(0.0, float(self.summary.get("total_value", 0)) - proceeds) + # 매입금액 감소 + self.summary["total_investment"] = max(0.0, float(self.summary.get("total_investment", 0)) - cost) + # 총 자산 = 예수금 + 주식평가 + self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"] + # 평가손익 재계산 + inv = self.summary["total_investment"] + val = self.summary["total_value"] + self.summary["total_profit_loss"] = val - inv + self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0 + self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0]) + self.mark_changed() def should_sync(self) -> bool: now = datetime.now() @@ -104,7 +149,8 @@ class PortfolioCache: total_stocks = len(holdings) total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) - total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings) + # 평가손익은 현재가 기준으로 직접 계산 (KIS evlu_pfls_amt는 지연될 수 있음) + total_profit_loss = total_value - total_investment total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0 # KIS API output2에서 예수금 및 총자산 정보 추출 diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 427b7b9..538c190 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -276,6 +276,9 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): all_analyzed_stocks = [] for s in raw_stocks: if s.stock_code not in seen: + # 전일 거래량 필터: 100만주 미만 종목 제외 + if s.volume is not None and s.volume < 1_000_000: + continue seen.add(s.stock_code) all_analyzed_stocks.append(s) diff --git a/backend/app/main.py b/backend/app/main.py index 6721b69..4241ec5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -642,6 +642,9 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): if msg_type == "set_subscriptions": codes = [str(c) for c in msg.get("stock_codes", [])] websocket_manager.set_subscriptions(client_id, codes) + # 구독 목록 변경 → KIS 스트림에 즉시 동기화 요청 + from app.services.kis_realtime_stream import kis_stream + kis_stream.notify_subscription_change() await websocket_manager.send_personal_message( json.dumps({ "type": "ack", "action": "set_subscriptions", diff --git a/backend/app/services/kis_realtime_stream.py b/backend/app/services/kis_realtime_stream.py index 55e9418..8748dfd 100644 --- a/backend/app/services/kis_realtime_stream.py +++ b/backend/app/services/kis_realtime_stream.py @@ -225,9 +225,11 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: "timestamp": fields[1] if len(fields) > 1 else "", } - if tr_id == TR_QUOTE and len(fields) >= 20: - # H0STASP0: [0]=종목코드, [3~7]=매도호가1~5, [8~12]=매수호가1~5 - # [13~17]=매도잔량1~5, [18~22]=매수잔량1~5 + if tr_id == TR_QUOTE and len(fields) >= 38: + # H0STASP0 실제 포맷 (KIS 개발가이드 기준): + # [0]=종목코드, [1]=영업시간, [2]=시간구분코드 + # [3~12]=매도호가1~10, [13~22]=매수호가1~10 + # [23~32]=매도호가잔량1~10, [33~42]=매수호가잔량1~10 def _f(i: int): return _safe_float(fields[i]) if len(fields) > i else None def _i(i: int): return _safe_int(fields[i]) if len(fields) > i else None return { @@ -235,11 +237,11 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: "tr_id": tr_id, "stock_code": fields[0], "ask1": _f(3), "ask2": _f(4), "ask3": _f(5), "ask4": _f(6), "ask5": _f(7), - "bid1": _f(8), "bid2": _f(9), "bid3": _f(10), "bid4": _f(11), "bid5": _f(12), - "ask_qty1": _i(13), "ask_qty2": _i(14), "ask_qty3": _i(15), - "ask_qty4": _i(16), "ask_qty5": _i(17), - "bid_qty1": _i(18), "bid_qty2": _i(19), "bid_qty3": _i(20), - "bid_qty4": _i(21), "bid_qty5": _i(22), + "bid1": _f(13), "bid2": _f(14), "bid3": _f(15), "bid4": _f(16), "bid5": _f(17), + "ask_qty1": _i(23), "ask_qty2": _i(24), "ask_qty3": _i(25), + "ask_qty4": _i(26), "ask_qty5": _i(27), + "bid_qty1": _i(33), "bid_qty2": _i(34), "bid_qty3": _i(35), + "bid_qty4": _i(36), "bid_qty5": _i(37), } return None @@ -294,6 +296,11 @@ class KisRealtimeStream: self._ws = None self._running = False self._approval_key: Optional[str] = None + self._sync_event: asyncio.Event = asyncio.Event() + + def notify_subscription_change(self): + """구독 목록 변경 시 즉시 KIS 동기화 트리거 (set_subscriptions 호출 직후 사용)""" + self._sync_event.set() async def _ensure_approval_key(self) -> bool: """approval_key 확보. 없으면 REST API 호출.""" @@ -429,8 +436,9 @@ class KisRealtimeStream: # ── 4단계: 데이터 수신 루프 ────────────────────────── async for raw_msg in ws: - # 주기적 구독 동기화 - if time.monotonic() - last_sync >= _SYNC_INTERVAL: + # 주기적 구독 동기화 (10초 주기 OR 구독 변경 즉시) + if self._sync_event.is_set() or time.monotonic() - last_sync >= _SYNC_INTERVAL: + self._sync_event.clear() await self._sync_subscriptions(ws) last_sync = time.monotonic() diff --git a/backend/app/services/kosdaq_scanner.py b/backend/app/services/kosdaq_scanner.py index e0e6ec6..e952005 100644 --- a/backend/app/services/kosdaq_scanner.py +++ b/backend/app/services/kosdaq_scanner.py @@ -18,6 +18,9 @@ logger = logging.getLogger(__name__) # 가격 필터 (CLAUDE.md 섹션 6.4) MIN_PRICE_KRW = 5_000 +# 전일 거래량 최솟값: 100만주 미만 종목 제외 (유동성 부족) +MIN_DAILY_VOLUME = 1_000_000 + # 거래대금 상위 20% 임계값 (하위 80% 제외) LIQUIDITY_PERCENTILE = 0.80 @@ -90,6 +93,8 @@ class KosdaqScanner: for td in tickers_data: if not self._price_filter(td.close): continue + if not self._volume_filter(td.volume): + continue if not self._liquidity_filter(td.turnover, liquidity_threshold): continue result = self._score(td) @@ -108,6 +113,10 @@ class KosdaqScanner: """CLAUDE.md 섹션 6.4: 국내 5,000원 이하 매수 금지""" return close >= self.min_price + def _volume_filter(self, volume: int) -> bool: + """전일 거래량 100만주 미만 종목 제외 (유동성 부족)""" + return volume >= MIN_DAILY_VOLUME + def _liquidity_filter(self, turnover: float, threshold: float) -> bool: """거래대금 상위 20% 이내 필터""" return turnover >= threshold diff --git a/backend/app/services/market_analyzer.py b/backend/app/services/market_analyzer.py index c1396b0..e1a2d36 100644 --- a/backend/app/services/market_analyzer.py +++ b/backend/app/services/market_analyzer.py @@ -10,7 +10,7 @@ from app.core.config import settings from app.core.market_schedule import is_market_open from app.services.korea_investment_api import korea_investment_api from app.services.mean_reversion_strategy import ( - MarketRegime, detect_market_regime, calculate_atr + MarketRegime, detect_market_regime, calculate_atr, calculate_adx ) from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, @@ -173,6 +173,10 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati if current_price == 0: return None + # 전일 거래량 필터: 100만주 미만 종목 제외 (유동성 부족) + if volume < 1_000_000: + return None + if len(chart_data) >= 2: prev_price = float(chart_data[-2].get("stck_clpr", current_price)) change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0 @@ -377,8 +381,8 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati atr_14_val = calculate_atr(_h, _l, _c) if len(chart_data) >= 29: adx_14_val = calculate_adx(_h, _l, _c) - except Exception: - pass + except Exception as _e: + logger.debug(f"ATR/ADX 계산 실패 {stock_code}: {_e}") if criteria.for_batch or len(signals) > 0 or score > 0: final_score = max(1, score) if len(signals) > 0 else score diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index bf2a004..1abd681 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -244,14 +244,20 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): score_val = 0.0 buy_keywords = [ - "정배열 눌림목 매수", "눌림목 진입 매수타점", "RSI 바닥 탈출 반등", + "정배열 눌림목 매수", "눌림목 진입 매수타점", "RSI 바닥 탈출 반등", "RSI 안정 상승", "스토캐스틱 과매도", "5일선 > 20일선", "20일선 > 60일선", "골든크로스", "볼린저 하한선 터치", "횡보장 — 평균회귀 유리", "거래량 증가와 상승" ] - should_buy = any(k in text_signals for k in buy_keywords) + # 거래량 부족 신호만 있는 경우 매수 금지 — 유효 신호에서 거래량부족 제거 후 판단 + signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] + text_signals_filtered = " ".join(signals_without_volume_weak) + should_buy = any(k in text_signals_filtered for k in buy_keywords) + # 거래량부족 신호만 있고 다른 유효 신호가 없으면 매수 금지 + if not signals_without_volume_weak and signals: + should_buy = False sell_keywords = [ - "역배열 하락 매도타점", "RSI 과매수", "스토캐스틱 과매수", "데드크로스", + "역배열 하락 매도타점", "RSI 과매수", "스토캐스틱 과매수", "데드크로스", "이동평균 역배열", "볼린저 상한선 돌파", "손절라인 이탈" ] should_sell = any(k in text_signals for k in sell_keywords) @@ -278,11 +284,15 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ml_hold_active = settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy if not ml_hold_active: should_buy = True + elif not has_rule: + # rule 없는 경우 급등과열 신호도 차단 + if "급등과열" in text_signals or "이격도 과열" in text_signals or "매수금지" in text_signals: + return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} if should_buy and not has_rule: strong_buy_kw = ["RSI 바닥 탈출 반등", "볼린저 하한선 터치", "눌림목 진입 매수타점", "정배열 눌림목 매수"] - has_strong_signal = any(k in text_signals for k in strong_buy_kw) - matched_count = sum(1 for k in buy_keywords if k in text_signals) + has_strong_signal = any(k in text_signals_filtered for k in strong_buy_kw) + matched_count = sum(1 for k in buy_keywords if k in text_signals_filtered) if not has_strong_signal and (matched_count < 2 or score_val < 2): should_buy = False @@ -337,6 +347,20 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): available_cash, total_assets = 0.0, 0.0 daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) + + # PENDING 포함 오늘 전체 중복 주문 차단 (가장 중요한 체크 — 맨 먼저 실행) + pending_check = await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(['EXECUTED', 'COMPLETED', 'PENDING']), + TradeHistory.created_at >= daily_cutoff, + ) + ) + pending_orders = pending_check.scalars().all() + if pending_orders: + return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} + daily_buy_check = await db.execute( select(TradeHistory) .where( @@ -366,18 +390,6 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} - recent_buy_cutoff = _get_kst_now() - timedelta(minutes=5) - check_recent = await db.execute( - select(TradeHistory).where( - TradeHistory.stock_code == stock_code, - TradeHistory.order_type == "BUY", - TradeHistory.created_at >= recent_buy_cutoff, - TradeHistory.status.in_(['EXECUTED', 'COMPLETED', 'PENDING']) - ) - ) - if check_recent.first(): - return {"action": "SKIP", "stock": stock_name, "reason": "recent_buy_5min"} - mcp_ok, mcp_reason = await _mcp_validate_buy(stock) if not mcp_ok: return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason} @@ -405,17 +417,6 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): else: order_result = await korea_investment_api.place_overseas_order(stock_code, "BUY", quantity, float(current_price), exchange_code) - recent_cutoff = _get_kst_now() - timedelta(seconds=10) - duplicate_check = await db.execute( - select(TradeHistory).where( - TradeHistory.stock_code == stock_code, - TradeHistory.order_type == "BUY", - TradeHistory.created_at >= recent_cutoff - ) - ) - if duplicate_check.scalar_one_or_none(): - return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} - total_amount = Decimal(str(current_price)) * quantity entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점" @@ -458,13 +459,10 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ) db.add(new_holding) await db.commit() - + try: from app.api.endpoints.portfolio import portfolio_cache - if portfolio_cache.summary: - old_cash = float(portfolio_cache.summary.get("available_cash", 0)) - portfolio_cache.summary["available_cash"] = max(0.0, old_cash - quantity * current_price) - portfolio_cache.change_flag = True + portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) except Exception: pass return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success} @@ -528,9 +526,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): await db.commit() try: from app.api.endpoints.portfolio import portfolio_cache - portfolio_cache.holdings = [] - portfolio_cache.summary = None - portfolio_cache.change_flag = True + avg = float(holding.average_price or current_price) + portfolio_cache.apply_sell(stock_code, quantity, current_price, avg) + portfolio_cache.holdings = [h for h in portfolio_cache.holdings if h.stock_code != stock_code] except Exception: pass return {"action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success} diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx index 196fa66..0069626 100644 --- a/frontend/src/components/PortfolioSummary.tsx +++ b/frontend/src/components/PortfolioSummary.tsx @@ -25,12 +25,21 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => { ); } - const { total_assets, total_value, total_investment, total_profit_loss, total_profit_loss_rate, available_cash } = summary; - const isProfit = total_profit_loss >= 0; + const { + total_assets, + total_value, // 주식 평가금액 + total_investment, // 총 매입금액 + total_profit_loss, + total_profit_loss_rate, + available_cash, // 예수금 (현금) + } = summary; + const isProfit = (total_profit_loss ?? 0) >= 0; + + const fmt = (v: number) => Math.round(v || 0).toLocaleString(); return (
- {/* 총 자산 */} + {/* 총 자산 (예수금 + 주식평가) */}

총 자산

@@ -39,25 +48,23 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {

- {Math.round(total_assets || 0).toLocaleString()}원 + {fmt(total_assets)}원

-
- 예수금: {Math.round(available_cash || 0).toLocaleString()}원 - 평가금액: {Math.round(total_value || 0).toLocaleString()}원 -
+

예수금 + 주식평가

- {/* 총 매입금액 */} + {/* 예수금 (현금) */}
-

총 매입금액

+

예수금

- {Math.round(total_investment || 0).toLocaleString()}원 + {fmt(available_cash)}원

+

주식평가: {fmt(total_value)}원

{/* 총 평가손익 */} @@ -69,8 +76,9 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {

- {total_profit_loss > 0 ? '+' : ''}{Math.round(total_profit_loss || 0).toLocaleString()}원 + {(total_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(total_profit_loss)}원

+

매입: {fmt(total_investment)}원

{/* 수익률 */} @@ -82,8 +90,9 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {

- {total_profit_loss_rate > 0 ? '+' : ''}{total_profit_loss_rate?.toFixed(2)}% + {(total_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(total_profit_loss_rate ?? 0).toFixed(2)}%

+

주식 매입 대비

); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f665098..e45b316 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -267,23 +267,37 @@ const Dashboard = () => { setLiveQuote(null) }, [selectedStock]) - // 실시간 평가손익/총자산 — livePrices 기반 매 틱마다 재계산 + // 실시간 주식평가금액/손익 — livePrices tick 기반 재계산 + // total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영 const liveSummary = useMemo(() => { if (!portfolioSummary || !portfolio) return portfolioSummary + + // live 현재가 기반 주식 평가금액 const liveValue = portfolio.reduce((sum, s) => { 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 liveProfitLoss = liveValue - totalInvestment const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0 + + // 총 자산 = 예수금(KIS 기준) + 주식 평가금액(live) + // KIS API total_assets가 있으면 그 예수금 부분만 분리해서 live 주식값 합산 + const availableCash = portfolioSummary.available_cash || 0 + const liveTotal = availableCash + liveValue + return { ...portfolioSummary, total_value: liveValue, + total_investment: totalInvestment, total_profit_loss: liveProfitLoss, total_profit_loss_rate: liveProfitLossRate, - total_assets: (portfolioSummary.available_cash || 0) + liveValue, + total_assets: liveTotal, } }, [portfolioSummary, portfolio, livePrices])