Files
auto-stock/backend/app/services/swing/exit_service.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

510 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""스윙 트레이딩 청산 서비스
기존 portfolio.py의 check_and_execute_exit_trades() 로직을 기반으로
strategy_type='swing' 포지션만 처리하도록 분리한 서비스.
청산 기준 (CLAUDE.md 섹션 5.2, 6.3):
- ATR 손절: 진입가 - 2.5*ATR (하드스탑과 비교하여 타이트한 쪽)
- 고정비율 폴백 손절: -7% (ATR 계산 불가 시)
- 1차 익절: RSI >= 55 OR 가격 > BB 중앙선 OR 수익 > 2*ATR -> 50% 매도
- 2차 익절: RSI >= 70 OR 가격 > BB 상단(99%) -> 전량 매도
- 트레일링스탑: 고점 - 1.5*ATR (잔여 50% 물량)
전략별 차별화 (CLAUDE.md 8.4):
- 평균회귀/눌림목: RSI 기반 단계별 익절
- 모멘텀 돌파: +10%/+20% 단계별 30% 매도 + 넓은 트레일링
아키텍처 규칙:
- DB 기록: db_worker 큐 경유 (enqueue_trade_history, enqueue_close_active_trade)
- loss_tracker: 전량 매도 시 record_trade_result_safe() + save_to_db()
- 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호)
"""
import logging
import time
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.models.models import Portfolio, ActiveTrade
from app.services.korea_investment_api import korea_investment_api
from app.services.mean_reversion_strategy import (
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
MarketRegime,
)
from app.services.market_analyzer import check_market_regime
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
from app.services.swing.position_manager import get_swing_holdings, get_swing_active_trade
from app.services.utils import _is_domestic
from app.core.json_helpers import _dumps
logger = logging.getLogger(__name__)
# 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드
_recent_exits: dict[str, float] = {}
EXIT_COOLDOWN_SEC = 180
def _check_momentum_exit(
current_price: float,
entry_price: float,
atr: float,
remaining_pct: float,
trailing_high: float | None,
) -> "ExitSignal | None":
"""모멘텀 돌파 전용 청산 로직 (CLAUDE.md 8.4).
일반 평균회귀와 다른 익절 전략을 사용:
- 이익을 최대한 키우기 위해 RSI 기반 익절 대신 수익률 기반 단계별 매도
- 손절은 평균회귀와 동일 (ATR 2.5배 vs 하드스탑 중 타이트한 쪽)
익절 단계:
1. +10% 이상: 30% 매도 (remaining_pct > 0.75일 때만)
2. +20% 이상: 추가 30% 매도 (remaining_pct > 0.45일 때만)
3. 잔량: 트레일링스탑 (고점 - 1.5*ATR)
Args:
current_price: 현재가
entry_price: 진입가
atr: ATR(14) 값
remaining_pct: 잔여 비율 (1.0=전량 보유)
trailing_high: 진입 이후 최고가 (트레일링스탑 기준)
Returns:
ExitSignal | None: 청산 신호, 없으면 None
Note:
[CRITICAL] 손절 계산에서 stop_price = max(atr_stop, pct_stop) 사용.
이것은 가격 비교이므로 max()가 올바름 (높은 가격 = 타이트한 손절).
CLAUDE.md에서 min()을 언급하는 것은 백분율 비교 기준.
"""
from app.services.mean_reversion_strategy import ExitSignal
if entry_price <= 0:
return None
profit_pct = (current_price - entry_price) / entry_price * 100
# 손절: ATR 2.5배 vs 하드스탑 (CLAUDE.md 5.2)
# stop_loss_pct는 음수 (-7.0 등), pct_stop은 진입가 대비 하드스탑 가격
# max()를 사용하는 이유: 가격 비교에서 높은 값 = 진입가에 가까운 = 타이트한 손절
stop_loss_pct = -abs(settings.STOP_LOSS_PERCENT)
atr_stop = entry_price - 2.5 * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
stop_price = max(atr_stop, pct_stop)
if current_price <= stop_price:
return ExitSignal(
action="SELL_ALL",
reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})",
sell_pct=1.0,
)
# 트레일링스탑 (잔량에 대해)
if trailing_high and trailing_high > entry_price:
trail_stop = trailing_high - 1.5 * atr
if current_price <= trail_stop and profit_pct > 0:
return ExitSignal(
action="SELL_ALL",
reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})",
sell_pct=1.0,
)
# 1차 익절: +10% → 30% 매도 (remaining > 0.65 = 아직 1차 매도 안 함)
# 1차 매도 후 remaining = 1.0 - 0.30 = 0.70 → 다음 사이클에서 0.70 > 0.65 방지
# 따라서 0.75 기준 유지: 1.0 > 0.75 트리거, 매도 후 0.70 <= 0.75 → 재트리거 방지
if profit_pct >= 10.0 and remaining_pct > 0.75:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30,
)
# 2차 익절: +20% → 30% 매도
# 1차 매도 후 remaining = 0.70, 2차 매도 후 remaining = 0.40
# 조건: remaining > 0.45 AND remaining <= 0.75 (1차와 겹치지 않게 상한 설정)
if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30,
)
return None
async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
"""스윙 포지션 손절/익절 체크 및 자동 매도 실행.
portfolio.py의 check_and_execute_exit_trades()와 동일한 로직이나
strategy_type='swing' 필터를 적용해 데이트레이딩 포지션을 제외함.
스케줄러에서 60초 주기로 호출.
처리 흐름 (종목별):
1. 스윙 보유 종목 조회 (get_swing_holdings)
2. 시장 국면 조회 (BULL/BEAR)
3. 실시간 현재가 조회 (KIS API)
4. ActiveTrade 조회 (분할 청산 상태)
5. 전략별 청산 분기:
- MOMENTUM_BREAKOUT: _check_momentum_exit()
- 평균회귀/눌림목/기본: check_exit_signal()
6. 고정비율 폴백 손절 (-7%)
7. 매도 수량 결정 (소량 분할 방지)
8. KIS API 매도 주문 실행
9. DB 기록 + loss_tracker 업데이트
Args:
db: AsyncSession 인스턴스
Returns:
list[dict]: 청산 실행 결과 목록
"""
executed_exits = []
try:
# strategy_type='swing' 포지션만 조회
_now_ts = time.time()
holdings = await get_swing_holdings(db)
if not holdings:
logger.debug("[SwingExit] 스윙 보유 종목 없음")
return executed_exits
# 시장 국면 (동적 계산 — 기존 코드의 하드코딩 BULL 개선)
try:
regime_dict = await check_market_regime()
regime_str = regime_dict.get("regime_200d", "bull") if isinstance(regime_dict, dict) else "bull"
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
except Exception:
regime = MarketRegime.BULL # 폴백
for holding in holdings:
try:
stock_code = holding.stock_code
stock_name = holding.stock_name or stock_code
# [H2 2026-09-27] Portfolio 행은 stock_code UNIQUE 라 같은 종목의
# 스윙+데이트레이딩 수량이 합산돼 있다. 행 전체를 청산 대상으로 잡으면
# DT 보유분까지 팔려 DT 인메모리와 불일치(주문거부/과다매도)가 생긴다.
# trade_executor._execute_sell 과 동일하게 DT 인메모리 수량을 차감한
# '스윙 보유분'만 대상으로 한다.
total_qty = int(holding.quantity or 0)
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_pos = _dts.positions.get(holding.stock_code)
_dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0)
except Exception:
_dt_qty = 0
quantity = max(0, total_qty - _dt_qty)
if quantity <= 0:
logger.info(
f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 "
f"— 스윙 보유분 없음, 청산 스킵"
)
continue
if _dt_qty > 0:
logger.info(
f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 "
f"→ 스윙 {quantity}주 대상"
)
avg_price = float(holding.average_price or 0)
market = getattr(holding, "market", None) or (
"KRX" if _is_domestic(stock_code) else "NASD"
)
domestic = _is_domestic(stock_code, market)
# ── 실시간 현재가 조회 ────────────────────────────────────
latest_price = float(holding.current_price or 0)
if domestic:
price_resp = await korea_investment_api.get_current_price(stock_code)
if price_resp and price_resp.get("output"):
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
else:
price_resp = await korea_investment_api.get_overseas_price(stock_code, market)
if price_resp and price_resp.get("output"):
latest_price = float(price_resp["output"].get("last", latest_price))
if latest_price <= 0 or avg_price <= 0:
continue
# ── ActiveTrade 조회 (분할 청산 상태) ────────────────────
active_trade = await get_swing_active_trade(db, stock_code)
# ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을
# 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭).
# [2026-09-27 후속] strategy_name 이 NULL 인 legacy/동기화 유입 행을
# 'swing' 으로 폴백하면 DB 에 'swing' 행이 없어 매칭 0건 →
# 유령 active 행이 영구 잔존한다. trade_executor 와 동일하게
# payload 의 strategy_name_not=DT_STRATEGY_NAMES 로 '비-DT 최신 행'을
# 직접 겨냥한다(전략명이 있으면 기존 동작 그대로).
_at_strategy = (active_trade.strategy_name if active_trade else None) or "swing"
_at_filter_not_dt = bool(
active_trade is not None and not active_trade.strategy_name
)
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0
trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None
# [S6] 트레일링스탑 고점(highest_since_entry) 매 사이클 갱신
if active_trade and latest_price > (trailing_high or 0):
trailing_high = latest_price
from app.services.db_worker import enqueue_active_trade_upsert
_th_payload = {"stock_code": stock_code}
if _at_filter_not_dt:
# 전략명 NULL 인 비-DT 행 대상 — 이름 동등 매칭 불가.
# strategy_name 과 동시에 넣으면 우선순위상 필터가 무력화된다.
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_TH
_th_payload["strategy_name_not"] = list(_DTN_TH)
else:
_th_payload["strategy_name"] = _at_strategy # DT 행 오염 방지
_th_payload["highest_since_entry"] = trailing_high
await enqueue_active_trade_upsert(_th_payload)
# ── 전략별 청산 분기 ────────────────────────────────────
exit_signal = None
_strategy = _at_strategy
if domestic:
try:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes)
if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price
# 모멘텀 돌파 전용 청산 (CLAUDE.md 8.4)
if _strategy == "MOMENTUM_BREAKOUT":
exit_signal = _check_momentum_exit(
latest_price, avg_price, atr,
remaining_pct, trailing_high,
)
else:
# 평균회귀 / 눌림목 / 기본 스윙
exit_signal = check_exit_signal(
regime=regime,
rsi=rsi,
close=latest_price,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=avg_price,
atr=atr,
lowest_since_entry=lowest_since,
remaining_pct=remaining_pct,
trailing_high=trailing_high,
)
except Exception as e:
logger.debug(f"[SwingExit] ATR 계산 실패 ({stock_code}): {e}")
# ── 고정 비율 폴백 손절 ───────────────────────────────────
if exit_signal is None:
stop_loss_pct = abs(float(getattr(settings, "STOP_LOSS_PERCENT", 6.0)))
# 적응형 모드(Crisis 등)의 손절값과 비교하여 타이트한 쪽 적용
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adaptive = get_current_adaptive_params()
if not _adaptive.fallback_used:
_adaptive_sl = abs(_adaptive.stop_loss_pct)
stop_loss_pct = min(stop_loss_pct, _adaptive_sl)
except Exception:
pass # 적응형 전략 로드 실패 시 기본값 유지
profit_rate = ((latest_price - avg_price) / avg_price) * 100
if profit_rate <= -stop_loss_pct:
from app.services.mean_reversion_strategy import ExitSignal
exit_signal = ExitSignal(
action="SELL_ALL",
reason=f"고정비율 손절 ({profit_rate:.1f}%)",
sell_pct=1.0,
)
if not exit_signal:
continue
# [2026-09-23] 상태 저장(remaining_pct·Portfolio)이 db_worker 큐 경유라
# 큐 지연·DLQ 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다.
# 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다.
_last_exit = _recent_exits.get(stock_code)
if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC:
logger.info(
f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 "
f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 스킵"
)
continue
# ── 매도 수량 결정 (소량 분할 방지) ─────────────────────
# [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율.
# check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가
# 1.0 → 0.70 → 0.40 (원본 대비 누적)을 가정하므로, 매도 수량도 반드시
# 원본 수량 기준으로 계산해야 remaining_pct 와 실제 잔량이 일치한다.
# (기존: sell_qty 는 '현재 잔량'×sell_pct, remaining_pct 는 '원본' 기준
# 차감 → 분할이 반복될수록 회계가 벌어졌다.)
# 원본 수량은 ActiveTrade 의 remaining_pct 로 역산한다.
sell_pct = getattr(exit_signal, "sell_pct", 1.0)
original_qty = quantity
if 0 < remaining_pct < 1.0:
original_qty = max(quantity, int(round(quantity / remaining_pct)))
if sell_pct >= 1.0:
sell_qty = quantity # 전량 매도(스윙 보유분 전체)
elif quantity <= 3:
# 3주 이하: 분할 매도 비효율 → 전량 매도
sell_qty = quantity
logger.info(f"[SwingExit] {stock_code}: {quantity}주 소량 → 전량매도 (분할 비효율)")
else:
sell_qty = min(max(int(original_qty * sell_pct), 1), quantity)
if sell_qty <= 0:
continue
# ── KIS API 매도 주문 ─────────────────────────────────────
logger.info(
f"[SwingExit] 청산 신호 → {stock_code} {stock_name} "
f"수량={sell_qty} 이유={exit_signal.reason}"
)
# [C1] 분산락+세마포어 보호 주문
from app.services.trade_executor import safe_place_order
order_resp = await safe_place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_qty,
price=None if domestic else latest_price,
is_domestic=domestic,
exchange_code=market or "NASD",
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
logger.error(f"[SwingExit] 매도 주문 실패 ({stock_code}): {err}")
continue
order_id = order_resp.get("output", {}).get("ODNO", "")
profit_loss = (latest_price - avg_price) * sell_qty
profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100
# ── DB 기록 (db_task_queue 경유) ──────────────────────────
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": latest_price,
"order_quantity": sell_qty,
"executed_price": latest_price,
"executed_quantity": sell_qty,
"total_amount": latest_price * sell_qty,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": exit_signal.reason,
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "swing",
})
_recent_exits[stock_code] = time.time()
# 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시)
from app.services.portfolio_cache_bridge import reflect_sell
# remaining_qty 는 Portfolio 행(스윙+DT 합산) 기준이어야 한다 —
# 스윙 보유분(quantity)만 쓰면 DT 수량이 사라진다.
await reflect_sell(
stock_code, sell_qty, latest_price, remaining_qty=total_qty - sell_qty
)
# 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신
if sell_qty >= quantity:
# strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다
# → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존.
if _at_filter_not_dt:
# 전략명 NULL 인 비-DT 행은 이름으로 닫을 수 없다 →
# strategy_name_not 필터로 비-DT 최신 행을 직접 겨냥.
from app.services.db_worker import (
DT_STRATEGY_NAMES as _DTN_CL,
enqueue_db_write,
)
await enqueue_db_write({
"type": "close_active_trade",
"data": {
"stock_code": stock_code,
"strategy_name_not": list(_DTN_CL),
},
})
else:
await enqueue_close_active_trade(
stock_code, strategy_name=_strategy or "swing"
)
else:
# 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지)
# [M5] 실제 잔량 기준으로 재계산 — sell_pct 단순 차감은 sell_qty 가
# 원본 기준일 때만 일치하므로, 반올림 오차까지 흡수하도록 잔량/원본으로 계산.
if original_qty > 0:
new_remaining = max(0.0, min(1.0, (quantity - sell_qty) / original_qty))
else:
new_remaining = max(0.0, remaining_pct - sell_pct)
from app.services.db_worker import enqueue_active_trade_upsert
_at_data = {"stock_code": stock_code}
if _at_filter_not_dt:
# active_trade 가 존재하는데 전략명이 NULL 인 경우에만 True →
# 아래 `active_trade is None` 신규생성 분기와 상호배타적이므로
# strategy_name 과 strategy_name_not 이 동시에 실리지 않는다.
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_UP
_at_data["strategy_name_not"] = list(_DTN_UP)
else:
_at_data["strategy_name"] = _strategy or "swing"
_at_data.update({
"remaining_pct": new_remaining,
# 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나
# 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴)
"quantity": quantity - sell_qty,
"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)
# LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지)
if sell_qty >= quantity:
is_loss = profit_loss < 0
await loss_tracker.record_trade_result_safe(
stock_code=stock_code,
is_loss=is_loss,
)
await loss_tracker.save_to_db(stock_code=stock_code)
executed_exits.append({
"stock_code": stock_code,
"stock_name": stock_name,
"sell_quantity": sell_qty,
"price": latest_price,
"profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"reason": exit_signal.reason,
})
logger.info(
f"[SwingExit] ✅ 청산 완료: {stock_code} {stock_name} "
f"수익률={profit_loss_rate:.1f}% 이유={exit_signal.reason}"
)
except Exception as e:
logger.error(f"[SwingExit] 종목 처리 오류 ({holding.stock_code}): {e}", exc_info=True)
except Exception as e:
logger.error(f"[SwingExit] 전체 오류: {e}", exc_info=True)
return executed_exits