fix: 스윙 부분매도 중복 실행 — ActiveTrade 미존재 포지션 처리

동기화로 유입된 포지션(ActiveTrade 행 없음)에 1차 익절(50%)이 매 사이클
반복 실행돼 전량 청산되는 문제 수정 (2026-09-22 두산에너빌리티 18→9→0주).

- exit_service: 부분매도 시 ActiveTrade 없으면 필수 키 포함해 생성, Portfolio 잔량 즉시 감소
- db_worker: 필수 키 없는 ActiveTrade 신규 upsert는 재시도 대신 경고 후 스킵
- portfolio.py sync 청산 경로 동일 수정
- 회귀 테스트 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 10:32:14 +09:00
parent c1deb8caae
commit 8ecedf65a8
4 changed files with 180 additions and 4 deletions

View File

@@ -341,11 +341,35 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
else:
# 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지)
new_remaining = max(0.0, remaining_pct - sell_pct)
from app.services.db_worker import enqueue_active_trade_upsert
await enqueue_active_trade_upsert({
from app.services.db_worker import (
enqueue_active_trade_upsert, enqueue_portfolio_upsert,
)
_at_data = {
"stock_code": stock_code,
"remaining_pct": new_remaining,
"highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high,
}
if active_trade is None:
# [FIX] ActiveTrade 행이 없는 포지션(동기화 유입 등)은 upsert가
# NOT NULL(quantity/entry_price) 위반으로 INSERT 실패 → remaining_pct
# 미저장 → 다음 사이클에 50% 매도 재실행(2026-09-22 두산에너빌리티).
# 신규 생성에 필요한 필수 키를 함께 전달한다.
_at_data.update({
"stock_name": stock_name,
"quantity": quantity - sell_qty,
"entry_price": avg_price,
"current_price": latest_price,
"strategy_name": "swing",
"status": "active",
"entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)",
})
await enqueue_active_trade_upsert(_at_data)
# [FIX] 잔여 수량도 즉시 반영 — 다음 사이클이 동기화 전 원래 수량
# 기준으로 매도 수량을 계산하지 않도록 (18주→9주 재매도 방지)
await enqueue_portfolio_upsert({
"stock_code": stock_code,
"quantity": quantity - sell_qty,
"current_price": latest_price,
})
# LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지)