fix: 수동 매도 후 보유종목 즉시 제거 (DB + 캐시 동시 반영)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user