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: if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] 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: def pick_float(d: dict, keys: list[str]) -> float:
for k in keys: for k in keys:
if k in d and d.get(k) not in (None, ""): if k in d and d.get(k) not in (None, ""):
@@ -135,26 +131,19 @@ class PortfolioCache:
continue continue
return 0.0 return 0.0
# 현금: 미수금 발생 방지를 위해 실제 현재 예수금 기준 사용
# d2_depo_amt(D+2 정산액)는 미청산 매도 대금 포함으로 실제보다 높게 나올 수 있음
# → dnca_tot_amt(예수금 총액, 현재 즉시 사용 가능) 우선 사용
available_cash = pick_float(output2, [ available_cash = pick_float(output2, [
"dnca_tot_amt", # 예수금 총액 (현재 실제 현금, 미수 방지 기준) "dnca_tot_amt",
"d2_depo_amt", # D+2 정산액 (fallback) "d2_depo_amt",
"d2_deposit", # 일부 응답 변형 키 "d2_deposit",
"nass_amt" # 순자산금액 (최후 fallback) "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"]) kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0: if kis_total_assets > 0:
# KIS API 값 우선 사용
total_assets = kis_total_assets total_assets = kis_total_assets
calculated_assets = total_value + available_cash calculated_assets = total_value + available_cash
# 차이가 크면 로그 (정상 - D+2 정산액 때문)
if abs(kis_total_assets - calculated_assets) > 1000: if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug( logger.debug(
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, " f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
@@ -162,10 +151,8 @@ class PortfolioCache:
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)" f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
) )
else: else:
# KIS API 값이 없으면 계산 (fallback)
total_assets = total_value + available_cash total_assets = total_value + available_cash
# 디버깅 로그(요약): 실제 사용된 값과 원본 일부 키 노출
try: try:
logger.debug( logger.debug(
"Portfolio summary calc - cash=%s, total_assets=%s, keys=%s", "Portfolio summary calc - cash=%s, total_assets=%s, keys=%s",
@@ -195,8 +182,8 @@ class PortfolioCache:
"total_profit_loss_rate": total_profit_loss_rate, "total_profit_loss_rate": total_profit_loss_rate,
"available_cash": available_cash, "available_cash": available_cash,
"total_assets": total_assets, "total_assets": total_assets,
"d2_settlement": d2_settlement, # D+2 정산액 "d2_settlement": d2_settlement,
"stock_value_from_api": stock_value_from_api # API의 유가평가액 "stock_value_from_api": stock_value_from_api
} }
portfolio_cache = PortfolioCache() portfolio_cache = PortfolioCache()
@@ -298,13 +285,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 오늘의 시작 시간 (00:00:00) 계산 # 오늘의 시작 시간 (00:00:00) 계산
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# 오늘 포함 최근 24시간 (혹시 자정 근처 이슈 방지)이지만, 주로 '최근 행동'이 중요함.
# 안전하게 '오늘 0시 이후'의 모든 거래를 가져옴.
recent_point = today_start recent_point = today_start
logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection") logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection")
# 복잡한 SQL 대신 단순 조회 후 애플리케이션 레벨에서 필터링
trades_stmt = select(TradeHistory).where( trades_stmt = select(TradeHistory).where(
TradeHistory.executed_at >= recent_point, TradeHistory.executed_at >= recent_point,
TradeHistory.status.in_(['EXECUTED', 'COMPLETED']) 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"]: for item in balance_data["output1"]:
stock_code = item.get("pdno") stock_code = item.get("pdno")
# 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리)
# 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음.
if stock_code in ghost_candidates: if stock_code in ghost_candidates:
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)") logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
# DB에서도 명시적 삭제 # 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") logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue continue
# KIS API 필드명 정확히 매핑 (로깅 추가)
average_price = Decimal(str(item.get("pchs_avg_pric", 0))) 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 = Decimal(str(item.get("evlu_pfls_amt", 0)))
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0))) profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
# 디버깅: 실제 KIS API 응답 로깅
logger.info( logger.info(
f"Portfolio sync - {stock_name}({stock_code}): " f"Portfolio sync - {stock_name}({stock_code}): "
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, " f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, "
@@ -420,8 +400,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 제거된 종목 반영 (DB에서 완전 삭제) # 제거된 종목 반영 (DB에서 완전 삭제)
for code, h in existing_all.items(): for code, h in existing_all.items():
if code not in processed_codes: if code not in processed_codes:
# 보호 로직: 최근 매수 종목이면 API 미반영 상태로 간주하고 유지
# (KIS API가 체결 직후 잔고 반영이 느릴 수 있음)
last_action = stock_status_map.get(code) last_action = stock_status_map.get(code)
if last_action == "BUY" and h.quantity > 0: if last_action == "BUY" and h.quantity > 0:
logger.info(f"🛡️ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists") 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) portfolio_cache.record_failure(e)
logger.error(f"Portfolio sync failed: {e}") logger.error(f"Portfolio sync failed: {e}")
if force: if force:
# 명시적 강제 동기화 요청 시에는 오류 전파
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
# 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용
return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)} return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)}
finally: finally:
portfolio_cache.sync_lock.release() 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 bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price lowest_since = min(lows[-5:]) if lows else avg_price
# ② 진입 후 최고가 (트레일링 스탑용) — 최근 10봉 최고값으로 근사
trailing_high = max(highs[-10:]) if highs else None trailing_high = max(highs[-10:]) if highs else None
# ① 시장 레짐 결정
# TODO: KOSPI 지수 일봉 기반 detect_market_regime() 으로 대체 예정
# 현재는 BULL 기본값 사용 (보수적 선택 — 1차 30% 익절)
regime = MarketRegime.BULL regime = MarketRegime.BULL
exit_signal = check_exit_signal( exit_signal = check_exit_signal(
@@ -669,7 +641,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
atr=atr, atr=atr,
lowest_since_entry=lowest_since, lowest_since_entry=lowest_since,
remaining_pct=remaining_pct, remaining_pct=remaining_pct,
trailing_high=trailing_high, # ② 트레일링 스탑 활성화 trailing_high=trailing_high,
) )
except Exception as chart_err: except Exception as chart_err:
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {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)) sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)") logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
elif profit_loss_rate <= settings.STOP_LOSS_PERCENT: elif profit_loss_rate <= settings.STOP_LOSS_PERCENT:
# 폴백: 고정 비율 손절 (-6%)
should_exit = True should_exit = True
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)" exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)"
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT: elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
@@ -742,10 +713,6 @@ async def check_and_execute_exit_trades(db: AsyncSession):
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트 # 분할 청산 시 ActiveTrade 잔여 비율 업데이트
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade: 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) new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct)
active_trade.remaining_pct = Decimal(str(round(new_remaining, 4))) active_trade.remaining_pct = Decimal(str(round(new_remaining, 4)))
await db.commit() await db.commit()
@@ -917,6 +884,10 @@ async def sell_stock(
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청" 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( order_result = await korea_investment_api.place_order(
stock_code=stock_code, stock_code=stock_code,
@@ -936,7 +907,7 @@ async def sell_stock(
trade = TradeHistory( trade = TradeHistory(
stock_code=stock_code, stock_code=stock_code,
stock_name=holding.stock_name, stock_name=stock_name,
order_type='SELL', order_type='SELL',
order_price=Decimal(str(price)) if price else None, order_price=Decimal(str(price)) if price else None,
order_quantity=quantity, order_quantity=quantity,
@@ -951,18 +922,38 @@ async def sell_stock(
executed_at=_get_kst_now() executed_at=_get_kst_now()
) )
db.add(trade) 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() 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() 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( asyncio.create_task(_create_journal_background(
trade_id=trade.id, trade_id=trade.id,
stock_code=stock_code, stock_code=stock_code,
stock_name=holding.stock_name, stock_name=stock_name,
order_type="SELL", order_type="SELL",
executed_price=float(executed_price), executed_price=float(executed_price),
quantity=quantity, quantity=quantity,
@@ -973,7 +964,7 @@ async def sell_stock(
return { return {
"success": True, "success": True,
"message": f"{holding.stock_name} {quantity}주 매도 주문 완료", "message": f"{stock_name} {quantity}주 매도 주문 완료",
"order_id": order_result.get('output', {}).get('ODNO'), "order_id": order_result.get('output', {}).get('ODNO'),
"profit_loss": float(profit_loss), "profit_loss": float(profit_loss),
"profit_loss_rate": float(profit_loss_rate) "profit_loss_rate": float(profit_loss_rate)