diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 63b274f..1d0d035 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -799,6 +799,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # ActiveTrade 잔량 정합 — 같은 종목에 active 행이 1개뿐일 때만 실제 보유량으로 교정. # (분할매도·수동매도 누락으로 DB 잔량이 실제와 달라지면 다음 청산이 과다매도) try: + from app.core.config import settings as _cfg # 모듈 상단 미임포트(함수 지역 사용) _held = {h.stock_code: h.quantity for h in new_holdings if h.quantity > 0} _at_rows = (await db.execute( select(ActiveTrade).where(ActiveTrade.status == "active") @@ -819,6 +820,41 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) f"🧹 ActiveTrade 잔량 교정: {_code} {_row.quantity} → {_actual}" ) _row.quantity = _actual + + # 외부 유입 포지션(다른 인스턴스/증권사 앱 매수 등)은 ActiveTrade 행이 없어 + # 손절·분할익절·트레일링 관리를 전혀 받지 못한다(2026-09-22 카카오뱅크 -13%). + # 보유 중인데 active 행이 없으면 현재 평균단가를 진입가로 보고 생성한다. + # 데이트레이딩 태깅 종목은 제외 — 엔진이 당일 청산 대상으로 복원해버린다. + _managed = set(_by_code) + for _h in new_holdings: + if _h.quantity <= 0 or _h.stock_code in _managed: + continue + if (_h.strategy_type or "swing") == "daytrading": + continue + _avg = float(_h.average_price or 0) + if _avg <= 0: + continue + _cur = float(_h.current_price or _avg) + db.add(ActiveTrade( + user_id=getattr(_h, "user_id", None), + stock_code=_h.stock_code, + stock_name=_h.stock_name, + quantity=_h.quantity, + entry_price=Decimal(str(_avg)), + current_price=Decimal(str(_cur)), + stop_loss=Decimal(str(round(_avg * (1 - abs(_cfg.STOP_LOSS_PERCENT) / 100), 2))), + status="active", + remaining_pct=Decimal("1.0"), + highest_since_entry=Decimal(str(max(_avg, _cur))), + strategy_name=_h.strategy_type or "swing", + entry_reason="외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)", + currency_code=_h.currency_code or "KRW", + exchange_code=_h.exchange_code, + )) + logger.info( + f"🆕 ActiveTrade 자동 등록: {_h.stock_name}({_h.stock_code}) " + f"{_h.quantity}주 @ {_avg:,.0f} — 외부 유입 포지션" + ) except Exception as _rec_err: logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}") diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index 071ae6f..125e2b9 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -154,3 +154,16 @@ def test_active_trade_patch_supports_quantity(): assert "quantity" in pf.ActiveTradeStatusUpdateRequest.model_fields src = inspect.getsource(pf.update_active_trade_status) assert "active_trade.quantity = request.quantity" in src + + +def test_sync_seeds_active_trade_for_external_positions(): + """외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다.""" + import inspect + from app.api.endpoints import portfolio as pf + src = inspect.getsource(pf.sync_portfolio) + assert "외부 유입 포지션 — 동기화 시 자동 등록" in src + # 데이트레이딩 태깅 종목은 제외 (엔진이 당일 청산 대상으로 복원해버림) + assert '(_h.strategy_type or "swing") == "daytrading"' in src + # 평균단가를 진입가로, 손절가는 STOP_LOSS_PERCENT 기준 + assert "_cfg.STOP_LOSS_PERCENT" in src + assert "entry_price=Decimal(str(_avg))" in src