fix: 수동 매도 즉시 반영 + 매도/매수 결과 토스트 + 당일 매도대금 가용현금 반영

- 프론트: 토스트 알림 시스템(lib/toast + ToastHost) 추가, 매도/매수 성공·실패를 화면에 표시
  (기존엔 console.log만, 실패는 블로킹 alert). 성공 토스트에 손익 표시.
- 매도 직후 예수금/평가액 요약 즉시 갱신(apply_sell), 최근 5분 내 수동 매도 종목은
  KIS 잔고 반영 지연으로 동기화가 되살리지 않도록 스킵(recent_sells).
- 예수금이 D+2 결제 기준(dnca_tot_amt)이라 당일 매도대금이 화면·데이트레이딩 가용현금에
  반영되지 않던 문제: 매수가능조회(inquire-psbl-order) ord_psbl_cash 를 우선 사용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:40:39 +09:00
parent e0178aaf0d
commit 496529782b
9 changed files with 181 additions and 12 deletions

View File

@@ -128,7 +128,10 @@ def _extract_balance_data(balance_data: dict) -> dict:
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
# _ord_psbl_cash: sync_portfolio()가 매수가능조회로 채워 넣는 주문가능현금
# (당일 매도대금 포함). 없으면 예수금총액(D+2 결제 기준) 폴백.
available_cash = _pick_float(output2, [
"_ord_psbl_cash",
"dnca_tot_amt",
"d2_depo_amt",
"d2_deposit",
@@ -212,6 +215,22 @@ class PortfolioCache:
self.next_retry_at: datetime | None = None
self.last_sync_success: bool = False
self.last_trade_id: int | None = None
# 최근 수동 매도 {code: (qty, ts)} — KIS 잔고 반영 지연으로 동기화가 판 종목을
# 되살리는 것 방지 (5분 TTL)
self.recent_sells: dict[str, tuple[int, datetime]] = {}
def note_sell(self, stock_code: str, quantity: int) -> None:
self.recent_sells[stock_code] = (quantity, datetime.now())
def recently_sold_qty(self, stock_code: str, ttl_sec: int = 300) -> int:
rec = self.recent_sells.get(stock_code)
if not rec:
return 0
qty, ts = rec
if (datetime.now() - ts).total_seconds() > ttl_sec:
self.recent_sells.pop(stock_code, None)
return 0
return qty
def mark_changed(self):
self.change_flag = True
@@ -483,6 +502,16 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}")
overseas_balance_data = {}
# 주문가능현금(당일 매도대금 포함) → output2에 주입해 available_cash로 사용
try:
if balance_data and balance_data.get("rt_cd") == "0" and balance_data.get("output2"):
_buyable = await korea_investment_api.get_buyable_cash()
if _buyable > 0:
_o2 = balance_data["output2"]
(_o2[0] if isinstance(_o2, list) else _o2)["_ord_psbl_cash"] = _buyable
except Exception as _bc_err:
logger.debug(f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}")
# 디버깅: KIS API 전체 응답 로깅
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
@@ -577,6 +606,14 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
for item in balance_data["output1"]:
stock_code = item.get("pdno")
# 최근 5분 내 수동 매도한 수량 이하로만 남아 있으면 KIS 반영 지연으로 판단
_sold_qty = portfolio_cache.recently_sold_qty(stock_code)
if _sold_qty and int(item.get("hldg_qty", 0)) <= _sold_qty:
logger.info(f"👻 최근 수동 매도 종목 스킵: {stock_code} (KIS 잔고 반영 대기)")
if stock_code in existing_all:
await db.delete(existing_all[stock_code])
continue
if stock_code in ghost_candidates:
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
# DB에서도 명시적 삭제
@@ -1333,6 +1370,7 @@ async def sell_stock(
# ✅ delete/commit 후 접근 불가를 대비해 미리 캡처
stock_name = holding.stock_name
original_holding_qty = holding.quantity
original_avg_price = float(holding.average_price or 0)
# 매도 주문 실행
order_result = await korea_investment_api.place_order(
@@ -1395,6 +1433,9 @@ async def sell_stock(
if h.stock_code == stock_code:
h.quantity -= quantity
break
# 예수금/평가액 요약도 즉시 반영 + 동기화가 되살리지 않도록 최근 매도 기록
portfolio_cache.note_sell(stock_code, quantity)
await portfolio_cache.apply_sell(stock_code, quantity, float(executed_price), original_avg_price)
portfolio_cache.mark_changed()
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")