From 55b6653f78ee939b49dba4e5df2c7fe44668f116 Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Wed, 25 Feb 2026 13:35:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=88=98=EB=8F=99=20=EB=A7=A4=EB=8F=84?= =?UTF-8?q?=20=ED=9B=84=20=EB=B3=B4=EC=9C=A0=EC=A2=85=EB=AA=A9=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=20(DB=20+=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/portfolio.py | 81 ++++++++++++-------------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 597e882..f25703f 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -122,10 +122,6 @@ class PortfolioCache: if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] - # 가급적 증권사 화면과 동일 기준 사용: - # - 현금: D+2 정산 기준 예수금(d2_depo_amt) - # - 총자산: 총평가금액(tot_evlu_amt) 우선, 없으면 유가평가+현금 계산 - # 필드명이 환경에 따라 다를 수 있어 다중 키 체킹 def pick_float(d: dict, keys: list[str]) -> float: for k in keys: if k in d and d.get(k) not in (None, ""): @@ -135,26 +131,19 @@ class PortfolioCache: continue return 0.0 - # 현금: 미수금 발생 방지를 위해 실제 현재 예수금 기준 사용 - # d2_depo_amt(D+2 정산액)는 미청산 매도 대금 포함으로 실제보다 높게 나올 수 있음 - # → dnca_tot_amt(예수금 총액, 현재 즉시 사용 가능) 우선 사용 available_cash = pick_float(output2, [ - "dnca_tot_amt", # 예수금 총액 (현재 실제 현금, 미수 방지 기준) - "d2_depo_amt", # D+2 정산액 (fallback) - "d2_deposit", # 일부 응답 변형 키 - "nass_amt" # 순자산금액 (최후 fallback) + "dnca_tot_amt", + "d2_depo_amt", + "d2_deposit", + "nass_amt" ]) - # 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확) - # tot_evlu_amt는 D+2 정산액 + 유가평가액을 포함한 실제 총자산 kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"]) if kis_total_assets > 0: - # KIS API 값 우선 사용 total_assets = kis_total_assets calculated_assets = total_value + available_cash - # 차이가 크면 로그 (정상 - D+2 정산액 때문) if abs(kis_total_assets - calculated_assets) > 1000: logger.debug( f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, " @@ -162,10 +151,8 @@ class PortfolioCache: f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)" ) else: - # KIS API 값이 없으면 계산 (fallback) total_assets = total_value + available_cash - # 디버깅 로그(요약): 실제 사용된 값과 원본 일부 키 노출 try: logger.debug( "Portfolio summary calc - cash=%s, total_assets=%s, keys=%s", @@ -195,8 +182,8 @@ class PortfolioCache: "total_profit_loss_rate": total_profit_loss_rate, "available_cash": available_cash, "total_assets": total_assets, - "d2_settlement": d2_settlement, # D+2 정산액 - "stock_value_from_api": stock_value_from_api # API의 유가평가액 + "d2_settlement": d2_settlement, + "stock_value_from_api": stock_value_from_api } portfolio_cache = PortfolioCache() @@ -298,13 +285,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 오늘의 시작 시간 (00:00:00) 계산 today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - # 오늘 포함 최근 24시간 (혹시 자정 근처 이슈 방지)이지만, 주로 '최근 행동'이 중요함. - # 안전하게 '오늘 0시 이후'의 모든 거래를 가져옴. recent_point = today_start logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection") - # 복잡한 SQL 대신 단순 조회 후 애플리케이션 레벨에서 필터링 trades_stmt = select(TradeHistory).where( TradeHistory.executed_at >= recent_point, TradeHistory.status.in_(['EXECUTED', 'COMPLETED']) @@ -350,8 +334,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) for item in balance_data["output1"]: stock_code = item.get("pdno") - # 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리) - # 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음. if stock_code in ghost_candidates: logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)") # DB에서도 명시적 삭제 @@ -366,7 +348,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity") continue - # KIS API 필드명 정확히 매핑 (로깅 추가) average_price = Decimal(str(item.get("pchs_avg_pric", 0))) # 실시간 현재가 (병렬 조회 결과 참조) @@ -387,7 +368,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0))) profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0))) - # 디버깅: 실제 KIS API 응답 로깅 logger.info( f"Portfolio sync - {stock_name}({stock_code}): " f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, " @@ -420,8 +400,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 제거된 종목 반영 (DB에서 완전 삭제) for code, h in existing_all.items(): if code not in processed_codes: - # 보호 로직: 최근 매수 종목이면 API 미반영 상태로 간주하고 유지 - # (KIS API가 체결 직후 잔고 반영이 느릴 수 있음) last_action = stock_status_map.get(code) if last_action == "BUY" and h.quantity > 0: logger.info(f"🛡️ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists") @@ -442,9 +420,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) portfolio_cache.record_failure(e) logger.error(f"Portfolio sync failed: {e}") if force: - # 명시적 강제 동기화 요청 시에는 오류 전파 raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") - # 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용 return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)} finally: portfolio_cache.sync_lock.release() @@ -650,12 +626,8 @@ async def check_and_execute_exit_trades(db: AsyncSession): bb_upper, bb_middle, bb_lower = bb lowest_since = min(lows[-5:]) if lows else avg_price - # ② 진입 후 최고가 (트레일링 스탑용) — 최근 10봉 최고값으로 근사 trailing_high = max(highs[-10:]) if highs else None - # ① 시장 레짐 결정 - # TODO: KOSPI 지수 일봉 기반 detect_market_regime() 으로 대체 예정 - # 현재는 BULL 기본값 사용 (보수적 선택 — 1차 30% 익절) regime = MarketRegime.BULL exit_signal = check_exit_signal( @@ -669,7 +641,7 @@ async def check_and_execute_exit_trades(db: AsyncSession): atr=atr, lowest_since_entry=lowest_since, remaining_pct=remaining_pct, - trailing_high=trailing_high, # ② 트레일링 스탑 활성화 + trailing_high=trailing_high, ) except Exception as chart_err: logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}") @@ -686,7 +658,6 @@ async def check_and_execute_exit_trades(db: AsyncSession): sell_quantity = max(1, int(quantity * exit_signal.sell_pct)) logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)") elif profit_loss_rate <= settings.STOP_LOSS_PERCENT: - # 폴백: 고정 비율 손절 (-6%) should_exit = True exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)" elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT: @@ -742,10 +713,6 @@ async def check_and_execute_exit_trades(db: AsyncSession): # 분할 청산 시 ActiveTrade 잔여 비율 업데이트 if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade: - # ③ 정확한 잔여 비율 계산: - # sell_pct = 현재 보유량 대비 비율이므로 - # new_remaining = remaining_pct × (1 - sell_pct) - # 예) remaining=0.70, sell_pct=0.50 → 0.70×0.50=0.35 (기존: 0.70-0.50=0.20 오류) new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct) active_trade.remaining_pct = Decimal(str(round(new_remaining, 4))) await db.commit() @@ -917,6 +884,10 @@ async def sell_stock( detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청" ) + # ✅ delete/commit 후 접근 불가를 대비해 미리 캡처 + stock_name = holding.stock_name + original_holding_qty = holding.quantity + # 매도 주문 실행 order_result = await korea_investment_api.place_order( stock_code=stock_code, @@ -936,7 +907,7 @@ async def sell_stock( trade = TradeHistory( stock_code=stock_code, - stock_name=holding.stock_name, + stock_name=stock_name, order_type='SELL', order_price=Decimal(str(price)) if price else None, order_quantity=quantity, @@ -951,18 +922,38 @@ async def sell_stock( executed_at=_get_kst_now() ) db.add(trade) + + # ✅ Portfolio DB 즉시 반영 (매도 후 보유종목에서 즉시 제거) + if quantity >= original_holding_qty: + # 전량 매도 → 레코드 삭제 + await db.delete(holding) + else: + # 분할 매도 → 수량 차감 + holding.quantity -= quantity + holding.current_price = Decimal(str(executed_price)) + await db.commit() - # 포트폴리오 캐시 무효화 + # ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거) + if quantity >= original_holding_qty: + portfolio_cache.holdings = [ + h for h in portfolio_cache.holdings + if h.stock_code != stock_code + ] + else: + for h in portfolio_cache.holdings: + if h.stock_code == stock_code: + h.quantity -= quantity + break portfolio_cache.mark_changed() - logger.info(f"✅ 수동 매도 완료: {holding.stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)") + logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)") # 매매일지 백그라운드 생성 asyncio.create_task(_create_journal_background( trade_id=trade.id, stock_code=stock_code, - stock_name=holding.stock_name, + stock_name=stock_name, order_type="SELL", executed_price=float(executed_price), quantity=quantity, @@ -973,7 +964,7 @@ async def sell_stock( return { "success": True, - "message": f"{holding.stock_name} {quantity}주 매도 주문 완료", + "message": f"{stock_name} {quantity}주 매도 주문 완료", "order_id": order_result.get('output', {}).get('ODNO'), "profit_loss": float(profit_loss), "profit_loss_rate": float(profit_loss_rate)