From 6207d13487f81058637d6b4233d2b4f1cc1153b7 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 10:42:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EC=9C=A0=EC=9E=85?= =?UTF-8?q?=20=ED=8F=AC=EC=A7=80=EC=85=98=20ActiveTrade=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=93=B1=EB=A1=9D=20=E2=80=94=20=EC=86=90=EC=A0=88?= =?UTF-8?q?=C2=B7=EB=B6=84=ED=95=A0=EC=9D=B5=EC=A0=88=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=ED=8E=B8=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 인스턴스/증권사 앱에서 매수돼 동기화로 들어온 포지션은 ActiveTrade 행이 없어 손절·분할익절·트레일링 관리를 전혀 받지 못했다(2026-09-22 카카오뱅크가 -13%까지 방치되다 청산). 동기화 시 보유 중인데 active 행이 없으면 평균단가를 진입가로 등록하고 손절가를 STOP_LOSS_PERCENT 기준으로 설정한다. 데이트레이딩 태깅 종목은 제외 (DT 엔진이 당일 청산 대상으로 복원해버리므로). Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/api/endpoints/portfolio.py | 36 +++++++++++++++++++++++++ backend/tests/test_bugfixes_20260923.py | 13 +++++++++ 2 files changed, 49 insertions(+) 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