fix: 수동 매도 후 보유종목 즉시 제거 (DB + 캐시 동시 반영)

This commit is contained in:
2026-02-25 13:35:45 +09:00
parent 7e26533cd6
commit 55b6653f78

View File

@@ -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)