Files
auto-stock/backend/app/services/swing/exit_service.py
shuim 7abbb3411c fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:25 +09:00

551 lines
29 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] = {}
# [F5 2026-09-28] "스윙 보유 종목 없음" 연속 횟수 — 조용한 무동작을 드러내기 위한 카운터.
# _EMPTY_HOLDINGS_WARN_AFTER 회 이상 연속되면 info → warning 으로 승격한다.
_empty_holdings_streak: int = 0
_EMPTY_HOLDINGS_WARN_AFTER: int = 10
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]: 청산 실행 결과 목록
"""
global _empty_holdings_streak
executed_exits = []
try:
# strategy_type='swing' 포지션만 조회
_now_ts = time.time()
holdings = await get_swing_holdings(db)
if not holdings:
# [F5 2026-09-28] 이 로그가 debug 였기 때문에 2026-05-15~09-21 4개월간
# 청산 판정이 사실상 멈춰 있던 것이 운영 로그에서 드러나지 않았다.
# info 로 올리고, 연속으로 계속되면 warning 으로 승격해 눈에 띄게 한다.
_empty_holdings_streak += 1
if _empty_holdings_streak >= _EMPTY_HOLDINGS_WARN_AFTER:
logger.warning(
f"[SwingExit] 스윙 보유 종목 없음 — {_empty_holdings_streak}회 연속 "
f"(청산 판정이 계속 no-op 이다: 실제 무보유인지, "
f"Portfolio.strategy_type 불일치인지 확인 필요)"
)
else:
logger.info(
f"[SwingExit] 스윙 보유 종목 없음 ({_empty_holdings_streak}회 연속)"
)
return executed_exits
_empty_holdings_streak = 0
# 시장 국면 (동적 계산 — 기존 코드의 하드코딩 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)
# [2026-09-28] 전략 간 당일 쿨다운 — 스윙이 손절한 종목을 같은 날
# 데이트레이딩이 다시 사는 것을 막는다(09-04 009420 -14% 손절 직후
# DT 워치리스트 잔류 확인). 조회는 daytrading/entry_service 의
# is_closed_today() 경로를 그대로 쓴다.
try:
from app.services.daytrading.position_manager import (
day_trading_state as _dts_cd,
)
# closed_today 변경은 trade_lock 보유가 규약(position_manager 참조)
async with _dts_cd.trade_lock:
_dts_cd.closed_today.add(stock_code)
# [M4 2026-09-28 / F9] 영속화 — 이 경로만 저장이 빠져 있어
# 재기동하면 "스윙이 손절한 종목을 같은 날 DT 가 재매수"가
# 되살아났다(전략 간 당일 쿨다운이 무효화). 락 해제 후 호출한다
# (Redis I/O 로 trade_lock 을 잡지 않는다).
await _dts_cd.save_daily_state()
except Exception as _cd_err:
logger.warning(
f"[SwingExit] DT 당일 재진입 차단 등록 실패 ({stock_code}): {_cd_err}"
)
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