diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 79913ac..ec0b433 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -33,6 +33,18 @@ logger = logging.getLogger(__name__) # 한국 시간대 KST = pytz.timezone('Asia/Seoul') +# 해외 거래소 코드 목록 +OVERSEAS_MARKETS = {"NASD", "NYSE", "AMEX", "TSEX", "SEHK", "SHAA", "SZAA", "HASE", "VNSE"} + +def _is_domestic(stock_code: str, market: str = None) -> bool: + """국내 종목 여부 판별. + market 컬럼이 있으면 우선 사용, 없으면 종목코드 패턴으로 폴백. + """ + if market: + return market not in OVERSEAS_MARKETS + # 폴백: 6자리 숫자이면 국내(KRX) + return stock_code.isdigit() and len(stock_code) == 6 + # Request/Response Models class SellRequest(BaseModel): stock_code: str @@ -144,15 +156,45 @@ class PortfolioCache: self.next_retry_at = None self.last_sync_success = True - def cache_summary(self, balance_data: dict = None): + def cache_summary(self, balance_data: dict = None, overseas_balance_data: dict = None, exchange_rate: float = 0.0): + """포트폴리오 요약 캐시 업데이트. + + Args: + balance_data: 국내 잔고 KIS API 응답 + overseas_balance_data: 해외 잔고 KIS API 응답 + exchange_rate: USD/KRW 환율 (해외 종목 원화 환산용, 0이면 미제공) + """ holdings = self.holdings + + def pick_float(d: dict, keys: list) -> float: + for k in keys: + if k in d and d.get(k) not in (None, ""): + try: + return float(d[k]) + except Exception: + continue + return 0.0 + total_stocks = len(holdings) - total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) + + # 국내/해외 분리하여 평가금액 계산 + domestic_value = sum( + float(h.current_price or 0) * h.quantity + for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None)) + ) + overseas_value_usd = sum( + float(h.current_price or 0) * h.quantity + for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None)) + ) + # 해외 원화 환산 (환율 미제공 시 overseas_balance_data output2의 tot_evlu_amt 사용) + overseas_value_krw = overseas_value_usd * exchange_rate if exchange_rate > 0 else 0.0 + + total_value = domestic_value + overseas_value_krw + total_investment = sum(float(h.average_price or 0) * h.quantity 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에서 예수금 및 총자산 정보 추출 available_cash = 0.0 total_assets = 0.0 @@ -164,19 +206,10 @@ class PortfolioCache: logger.info(f"💰 output2 data: {output2}") else: logger.warning("⚠️ No 'output2' in balance_data!") - + if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] - def pick_float(d: dict, keys: list[str]) -> float: - for k in keys: - if k in d and d.get(k) not in (None, ""): - try: - return float(d.get(k)) - except Exception: - continue - return 0.0 - available_cash = pick_float(output2, [ "dnca_tot_amt", "d2_depo_amt", @@ -189,7 +222,6 @@ class PortfolioCache: if kis_total_assets > 0: total_assets = kis_total_assets calculated_assets = total_value + available_cash - if abs(kis_total_assets - calculated_assets) > 1000: logger.debug( f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, " @@ -210,16 +242,27 @@ class PortfolioCache: ) except Exception: pass - + # D+2 정산액 및 기타 상세 정보 추출 d2_settlement = 0.0 stock_value_from_api = 0.0 - if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"]) stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]) + # 해외 잔고 합산 (원화 환산 총자산에 포함) + overseas_total_krw = 0.0 + if overseas_balance_data and "output2" in overseas_balance_data: + ovrs_out2 = overseas_balance_data["output2"] + if isinstance(ovrs_out2, list): + ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {} + # tot_evlu_amt: 해외주식 평가금액 합계 (원화) + overseas_total_krw = pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"]) + if overseas_total_krw > 0: + total_assets += overseas_total_krw + logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원") + self.summary = { "total_stocks": total_stocks, "total_value": total_value, @@ -229,7 +272,11 @@ class PortfolioCache: "available_cash": available_cash, "total_assets": total_assets, "d2_settlement": d2_settlement, - "stock_value_from_api": stock_value_from_api + "stock_value_from_api": stock_value_from_api, + # 해외 관련 추가 필드 + "overseas_value_usd": overseas_value_usd, + "overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw, + "exchange_rate": exchange_rate, } portfolio_cache = PortfolioCache() @@ -247,7 +294,8 @@ class PortfolioResponse(BaseModel): current_price: Decimal = None profit_loss: Decimal = None profit_loss_rate: Decimal = None - + market: str = "KRX" + class Config: from_attributes = True @@ -305,7 +353,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) try: portfolio_cache.last_attempt = datetime.now() try: - balance_data = await korea_investment_api.get_balance() + # 국내·해외 잔고 병렬 조회 + balance_data, overseas_balance_data = await asyncio.gather( + korea_investment_api.get_balance(), + korea_investment_api.get_overseas_balance(), + return_exceptions=True, + ) + if isinstance(balance_data, Exception): + logger.warning(f"⚠️ 국내 잔고 조회 실패: {balance_data}") + balance_data = {} + if isinstance(overseas_balance_data, Exception): + logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}") + overseas_balance_data = {} # 디버깅: KIS API 전체 응답 로깅 logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}") @@ -430,6 +489,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) h.current_price = current_price h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate + h.market = "KRX" new_holdings.append(h) else: new_holding = Portfolio( @@ -439,10 +499,87 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) average_price=average_price, current_price=current_price, profit_loss=profit_loss, - profit_loss_rate=profit_loss_rate + profit_loss_rate=profit_loss_rate, + market="KRX", ) db.add(new_holding) new_holdings.append(new_holding) + + # ── 해외 잔고 처리 ───────────────────────────────────────────────── + if overseas_balance_data and overseas_balance_data.get("rt_cd") == "0" and "output1" in overseas_balance_data: + ovrs_items = overseas_balance_data["output1"] or [] + # 해외 현재가 병렬 조회 + _ovrs_codes = [ + item.get("ovrs_pdno") for item in ovrs_items + if item.get("ovrs_pdno") and int(item.get("ord_psbl_qty", 0)) > 0 + ] + if _ovrs_codes: + _ovrs_price_results = await asyncio.gather( + *[korea_investment_api.get_overseas_price(c, item.get("ovrs_excg_cd", "NASD")) + for c, item in zip(_ovrs_codes, ovrs_items)], + return_exceptions=True, + ) + _ovrs_price_map = dict(zip(_ovrs_codes, _ovrs_price_results)) + else: + _ovrs_price_map = {} + + for item in ovrs_items: + stock_code = item.get("ovrs_pdno") + if not stock_code: + continue + quantity = int(item.get("ord_psbl_qty", 0)) + if quantity <= 0: + continue + + stock_name = item.get("ovrs_item_name", stock_code) + exchange_code = item.get("ovrs_excg_cd", "NASD") + average_price = Decimal(str(item.get("pchs_avg_pric", 0))) + + # 실시간 현재가 + price_resp = _ovrs_price_map.get(stock_code) + try: + if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp: + realtime_price = float(price_resp["output"].get("last", 0)) + current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("now_pric2", 0))) + else: + current_price = Decimal(str(item.get("now_pric2", 0))) + except Exception: + current_price = Decimal(str(item.get("now_pric2", 0))) + + profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0))) + profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt2", 0))) + + logger.info( + f"[해외] Portfolio sync - {stock_name}({stock_code}/{exchange_code}): " + f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}" + ) + + processed_codes.add(stock_code) + if stock_code in existing_all: + h = existing_all[stock_code] + h.stock_name = stock_name + h.quantity = quantity + h.average_price = average_price + h.current_price = current_price + h.profit_loss = profit_loss + h.profit_loss_rate = profit_loss_rate + h.market = exchange_code + new_holdings.append(h) + else: + new_holding = Portfolio( + stock_code=stock_code, + stock_name=stock_name, + quantity=quantity, + average_price=average_price, + current_price=current_price, + profit_loss=profit_loss, + profit_loss_rate=profit_loss_rate, + market=exchange_code, + ) + db.add(new_holding) + new_holdings.append(new_holding) + # ────────────────────────────────────────────────────────────────── + # 제거된 종목 반영 (DB에서 완전 삭제) for code, h in existing_all.items(): if code not in processed_codes: @@ -456,7 +593,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) await db.delete(h) await db.commit() portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] - portfolio_cache.cache_summary(balance_data) # balance_data 전달 + portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달 portfolio_cache.last_synced = datetime.now() portfolio_cache.change_flag = False portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db) @@ -638,12 +775,19 @@ async def check_and_execute_exit_trades(db: AsyncSession): stock_name = holding.stock_name quantity = holding.quantity avg_price = float(holding.average_price or 0) + market = getattr(holding, "market", None) or ("KRX" if _is_domestic(stock_code) else "NASD") + domestic = _is_domestic(stock_code, market) - # 실시간 가격 확인 - price_resp = await korea_investment_api.get_current_price(stock_code) + # ── 실시간 가격 확인 (국내/해외 분기) ─────────────────────── latest_price = float(holding.current_price or 0) - if price_resp and price_resp.get("output"): - latest_price = int(price_resp["output"].get("stck_prpr", latest_price)) + if domestic: + price_resp = await korea_investment_api.get_current_price(stock_code) + if price_resp and price_resp.get("output"): + latest_price = int(price_resp["output"].get("stck_prpr", latest_price)) + else: + price_resp = await korea_investment_api.get_overseas_price(stock_code, market) + if price_resp and price_resp.get("output"): + latest_price = float(price_resp["output"].get("last", latest_price)) if latest_price <= 0 or avg_price <= 0: continue @@ -660,43 +804,77 @@ async def check_and_execute_exit_trades(db: AsyncSession): active_trade = at_result.scalar_one_or_none() remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0 - # ATR 기반 청산 시도 (차트 데이터 필요) + # ── ATR 기반 청산 시도 (국내만, 해외는 고정 비율 폴백) ────── exit_signal = None - try: - chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30) - chart_data = chart_resp.get("output2", []) - if len(chart_data) >= 20: - closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] - highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] - lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] + if domestic: + try: + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30) + chart_data = chart_resp.get("output2", []) + if len(chart_data) >= 20: + closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] + highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] + lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] - atr = calculate_atr(highs, lows, closes) - rsi = calculate_rsi(closes) - bb = calculate_bollinger(closes) + atr = calculate_atr(highs, lows, closes) + rsi = calculate_rsi(closes) + bb = calculate_bollinger(closes) - if atr and rsi is not None and bb: - bb_upper, bb_middle, bb_lower = bb - lowest_since = min(lows[-5:]) if lows else avg_price + if atr and rsi is not None and bb: + bb_upper, bb_middle, bb_lower = bb + lowest_since = min(lows[-5:]) if lows else avg_price + trailing_high = max(highs[-10:]) if highs else None + regime = MarketRegime.BULL - trailing_high = max(highs[-10:]) if highs else None + exit_signal = check_exit_signal( + regime=regime, + rsi=rsi, + close=latest_price, + bb_upper=bb_upper, + bb_middle=bb_middle, + bb_lower=bb_lower, + entry_price=avg_price, + atr=atr, + lowest_since_entry=lowest_since, + remaining_pct=remaining_pct, + trailing_high=trailing_high, + ) + except Exception as chart_err: + logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}") + else: + # 해외: 해외 차트로 ATR 계산 시도 + try: + chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, market) + chart_data = chart_resp.get("output2", []) + if len(chart_data) >= 20: + closes = [float(d.get("clos", 0)) for d in reversed(chart_data)] + highs = [float(d.get("high", 0)) for d in reversed(chart_data)] + lows = [float(d.get("low", 0)) for d in reversed(chart_data)] - regime = MarketRegime.BULL + atr = calculate_atr(highs, lows, closes) + rsi = calculate_rsi(closes) + bb = calculate_bollinger(closes) - exit_signal = check_exit_signal( - regime=regime, - rsi=rsi, - close=latest_price, - bb_upper=bb_upper, - bb_middle=bb_middle, - bb_lower=bb_lower, - entry_price=avg_price, - atr=atr, - lowest_since_entry=lowest_since, - remaining_pct=remaining_pct, - trailing_high=trailing_high, - ) - except Exception as chart_err: - logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}") + if atr and rsi is not None and bb: + bb_upper, bb_middle, bb_lower = bb + lowest_since = min(lows[-5:]) if lows else avg_price + trailing_high = max(highs[-10:]) if highs else None + regime = MarketRegime.BULL + + exit_signal = check_exit_signal( + regime=regime, + rsi=rsi, + close=latest_price, + bb_upper=bb_upper, + bb_middle=bb_middle, + bb_lower=bb_lower, + entry_price=avg_price, + atr=atr, + lowest_since_entry=lowest_since, + remaining_pct=remaining_pct, + trailing_high=trailing_high, + ) + except Exception as chart_err: + logger.debug(f"[해외] ATR 청산 계산 실패 ({stock_code}): {chart_err}") should_exit = False exit_reason = "" @@ -717,24 +895,37 @@ async def check_and_execute_exit_trades(db: AsyncSession): exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)" if should_exit: - logger.info(f"{exit_reason} - {stock_name}({stock_code})") - try: - order_result = await korea_investment_api.place_order( - stock_code=stock_code, + logger.info(f"{exit_reason} - {stock_name}({stock_code}) [{'국내' if domestic else '해외/' + market}]") + # ── 주문 실행 (국내/해외 분기) ─────────────────────────── + if domestic: + try: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=sell_quantity, + price=order_price + ) + except Exception as e: + logger.warning(f"지정가 실패, 시장가 대체: {e}") + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=sell_quantity, + price=None + ) + else: + order_result = await korea_investment_api.place_overseas_order( + symbol=stock_code, order_type="SELL", quantity=sell_quantity, - price=order_price - ) - except Exception as e: - logger.warning(f"지정가 실패, 시장가 대체: {e}") - order_result = await korea_investment_api.place_order( - stock_code=stock_code, - order_type="SELL", - quantity=sell_quantity, - price=None + price=order_price, + exchange_code=market, ) - executed_price = Decimal(str(order_result.get("output", {}).get("ord_prc", order_price))) + # 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용 + _out = order_result.get("output", {}) + _raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price + executed_price = Decimal(str(_raw_price)) total_amount = Decimal(str(executed_price)) * sell_quantity profit_loss = (float(executed_price) - avg_price) * sell_quantity diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 0d99835..e76674c 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -109,7 +109,7 @@ class TradeHistory(Base): class Portfolio(Base): __tablename__ = "portfolio" - + id = Column(Integer, primary_key=True, index=True) stock_code = Column(String(20), nullable=False, unique=True) stock_name = Column(String(100)) @@ -118,6 +118,7 @@ class Portfolio(Base): current_price = Column(Numeric(15, 2)) profit_loss = Column(Numeric(15, 2)) profit_loss_rate = Column(Numeric(10, 4)) + market = Column(String(10), nullable=True, default="KRX") # KRX, NASD, NYSE, AMEX updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index d0cea99..5cd368c 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -773,7 +773,39 @@ class KoreaInvestmentAPI: except Exception as e: logger.error(f"Error fetching balance: {e}") raise - + + async def get_overseas_balance(self) -> Dict[str, Any]: + """해외주식 잔고 조회 (미국 기준) + + 실전: TTTS3012R / 모의: VTTS3012R + 응답 output1: 보유 종목 목록 (ovrs_pdno, ovrs_item_name, ovrs_excg_cd, ord_psbl_qty, pchs_avg_pric, now_pric2, evlu_pfls_amt, evlu_pfls_rt2) + 응답 output2: 계좌 요약 (tot_evlu_amt, frcr_evlu_tota, usd_amt, ovrs_tot_pfls) + """ + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + + is_virtual = "vts" in self.base_url.lower() + tr_id = "VTTS3012R" if is_virtual else "TTTS3012R" + + result, _ = await self._make_request( + "GET", + "/uapi/overseas-stock/v1/trading/inquire-balance", + tr_id, + params={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "OVRS_EXCG_CD": "NASD", # 미국 전체 (NASD 조회 시 NYSE·AMEX 포함) + "TR_CRCY_CD": "USD", + "CTX_AREA_FK200": "", + "CTX_AREA_NK200": "", + } + ) + return result + except Exception as e: + logger.error(f"Error fetching overseas balance: {e}") + return {} + async def place_order( self, stock_code: str,