하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1226 lines
66 KiB
Python
1226 lines
66 KiB
Python
"""스윙 트레이딩 청산 서비스
|
||
|
||
기존 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 asyncio
|
||
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
|
||
|
||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||
|
||
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()가 올바름 (높은 가격 = 타이트한 손절).
|
||
음수 퍼센트로 비교해도 0 에 가까운(큰) 쪽이 타이트하므로 역시 max() 다
|
||
(CLAUDE.md §5.2). min() 이 맞는 곳은 장중 최소 손절폭 보장(§7) 뿐이다.
|
||
"""
|
||
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()를 사용하는 이유: 가격 비교에서 높은 값 = 진입가에 가까운 = 타이트한 손절
|
||
# [H4 2026-10-02] 손절률·ATR 배수는 평균회귀 check_exit_signal 과 같은 소스
|
||
# (적응형 → strategy_cfg → config, 고변동 ATR/종가>3% 면 STOP_LOSS_HIGH_VOL).
|
||
# 기존: STOP_LOSS_PERCENT 고정 + 2.5×ATR 리터럴.
|
||
from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2
|
||
stop_loss_pct, _atr_mult = resolve_swing_stop_params_v2(atr, current_price)
|
||
atr_stop = entry_price - _atr_mult * 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
|
||
|
||
|
||
# ── [M-2 2026-10-02 5회차] 결과 불명 매도 ─────────────────────────────────────
|
||
# safe_place_order 의 msg_cd 체계(trade_executor·korea_investment_api):
|
||
# 전송 전 실패 = KIS_PRESEND_* · TOKEN_UNAVAILABLE · ORDER_LOCK_BUSY (+ msg1 order_lock_busy)
|
||
# 전송 후 불명 = KIS_TRANSPORT_UNKNOWN, msg_cd 없는 rt_cd "-1", 응답 자체 없음
|
||
_PRESEND_MSG_CDS_V2 = frozenset({"TOKEN_UNAVAILABLE", "ORDER_LOCK_BUSY"})
|
||
_TRANSPORT_UNKNOWN_MSG_CD_V2 = "KIS_TRANSPORT_UNKNOWN"
|
||
# {종목: {"qty": 불명 주문수량, "pre_total": 주문 직전 Portfolio 합산수량, "since": epoch, "failures": n}}
|
||
_pending_unknown_sells: dict[str, dict] = {}
|
||
|
||
|
||
def _classify_swing_sell_response_v2(resp) -> str:
|
||
"""매도 주문 응답 → "accepted" | "unknown" | "retry".
|
||
|
||
retry = 전송 전 실패(주문이 KIS 에 도달하지 않음) 또는 KIS 업무 거부(rt_cd "0"/"-1" 외) —
|
||
다음 주기 재시도가 안전하다. unknown = 접수 여부를 알 수 없음 — 확인 전 재매도 금지.
|
||
"""
|
||
if not isinstance(resp, dict) or resp.get("rt_cd") is None:
|
||
return "unknown"
|
||
rt = str(resp.get("rt_cd"))
|
||
mc = str(resp.get("msg_cd") or "")
|
||
if rt == "0":
|
||
return "accepted"
|
||
if mc.startswith("KIS_PRESEND_") or mc in _PRESEND_MSG_CDS_V2 or str(resp.get("msg1") or "") == "order_lock_busy":
|
||
return "retry"
|
||
if mc == _TRANSPORT_UNKNOWN_MSG_CD_V2 or (rt == "-1" and not mc):
|
||
return "unknown"
|
||
return "retry"
|
||
|
||
|
||
async def _fetch_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None":
|
||
"""KIS 잔고에서 (보유수량, 주문가능수량). 조회 실패·오류 응답·형식 이상은 None.
|
||
|
||
output1 에 종목이 없으면 정상 응답 = (0, 0). 주문가능수량이 없으면 보유수량으로 본다.
|
||
주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다(미체결 불명 주문 포함).
|
||
"""
|
||
try:
|
||
bal = await korea_investment_api.get_balance()
|
||
except Exception as e:
|
||
logger.warning(f"[SwingExit] KIS 잔고 조회 예외 ({stock_code}): {type(e).__name__}: {e}")
|
||
return None
|
||
if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0"):
|
||
return None
|
||
rows = bal.get("output1")
|
||
if not isinstance(rows, list):
|
||
return None
|
||
hq = sq = 0
|
||
found = False
|
||
try:
|
||
for h in rows:
|
||
if not isinstance(h, dict) or str(h.get("pdno", "") or "") != stock_code:
|
||
continue
|
||
found = True
|
||
_h = max(0, int(float(h.get("hldg_qty", 0) or 0)))
|
||
_s = _h
|
||
if h.get("ord_psbl_qty") not in (None, ""):
|
||
_s = max(0, min(_h, int(float(h.get("ord_psbl_qty") or 0))))
|
||
hq += _h
|
||
sq += _s
|
||
except (TypeError, ValueError):
|
||
return None
|
||
# [7회차 H-A] get_balance 는 첫 페이지만 조회한다(연속조회 없음 — 모의 20행). 종목을 못 찾았는데
|
||
# 페이지가 가득 찼으면 "보유 0"이 아니라 "다음 페이지에 있을 수 있음" → 조회 실패(None)로 돌려
|
||
# 보류·연속 실패 error 경로를 타게 한다. "(0,0)"으로 두면 손절 주문이 영영 나가지 않는다.
|
||
if not found and len(rows) >= int(settings.SWING_BALANCE_FULL_PAGE_ROWS):
|
||
logger.error(
|
||
f"[SwingExit] {stock_code} KIS 잔고 첫 페이지({len(rows)}행)에 없음 — 페이지 초과 가능, "
|
||
"매도 보류(get_balance 연속조회 미구현, CLAUDE.md §8.5)"
|
||
)
|
||
return None
|
||
return hq, sq
|
||
|
||
|
||
# [C-1 2026-10-02 6회차] 종목별 매도 직전 KIS 잔고 확인 연속 실패 횟수
|
||
_presell_check_failures: dict[str, int] = {}
|
||
|
||
|
||
async def _presell_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None":
|
||
"""국내 스윙 매도 직전 KIS 실보유 (보유, 주문가능). 실패면 None(+warning, 연속이면 error).
|
||
|
||
[C-1 6회차] 백엔드에는 주기적 Portfolio sync 가 없어 Portfolio 수량이 실보유보다 클 수 있다
|
||
(결과 불명 체결·외부 매도 등). 매도 수량 상한을 항상 KIS 실보유로 잡아 DT 물량 매도를
|
||
구조적으로 막는다. 조회 실패 시 매도 보류 — 청산 지연을 감수한다(과다매도보다 낫다).
|
||
"""
|
||
got = await _fetch_kis_holding_v2(stock_code)
|
||
if got is None:
|
||
n = _presell_check_failures.get(stock_code, 0) + 1
|
||
_presell_check_failures[stock_code] = n
|
||
_msg = (f"[SwingExit] {stock_code}: 매도 직전 KIS 잔고 확인 실패 ({n}회 연속) — 매도 보류")
|
||
if n >= int(settings.SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER):
|
||
logger.error("🚨 " + _msg + " (수동 확인 필요)")
|
||
else:
|
||
logger.warning(_msg)
|
||
return None
|
||
_presell_check_failures.pop(stock_code, None)
|
||
return got
|
||
|
||
|
||
def _trigger_portfolio_sync_v2() -> None:
|
||
"""[C-1(b)] 결과 불명 매도 직후 Portfolio sync 를 백그라운드로 요청(trade_executor 와 같은 경로).
|
||
|
||
sync_portfolio 는 CLAUDE.md §3.2 허용 예외 ① 이다. 실패는 warning — 장부 정정의 1차 수단은
|
||
다음 주기의 KIS 잔고 대조(_reconcile_unknown_sell_v2)다.
|
||
"""
|
||
try:
|
||
from app.services.trade_executor import _delayed_sync_portfolio
|
||
asyncio.get_running_loop().create_task(_delayed_sync_portfolio())
|
||
except Exception as e:
|
||
logger.warning(f"[SwingExit] 결과 불명 매도 후 Portfolio sync 요청 실패: {type(e).__name__}: {e}")
|
||
|
||
|
||
async def _reconcile_unknown_sell_v2(stock_code: str, kis_hldg: int, kis_sellable: int) -> bool:
|
||
"""결과 불명 매도의 체결분을 KIS 실보유 감소로 확인해 장부에 반영. 반영했으면 True.
|
||
|
||
[C-1(a) 6회차] 체결분 = min(주문수량, 주문 직전 KIS 보유 − 현재 보유). 체결분이 있으면
|
||
COMPLETED TradeHistory·reflect_sell·ActiveTrade(분할이면 의도 단계 remaining_pct, 전량이면
|
||
close + loss_tracker + save_to_db)로 반영하고 대기를 해제한다 — 호출부는 그 주기를 skip 해
|
||
같은 단계 재발동을 막는다. 체결분이 없고 미체결 주문도 없으면(주문가능=보유) 미접수로 보고
|
||
대기를 해제, 주문가능<보유(미체결 주문 잔존)면 대기를 유지한다.
|
||
"""
|
||
pend = _pending_unknown_sells.get(stock_code)
|
||
if not pend:
|
||
return False
|
||
filled = min(int(pend["qty"]), max(0, int(pend["pre_hldg"]) - int(kis_hldg)))
|
||
if filled <= 0:
|
||
if kis_sellable >= kis_hldg:
|
||
_pending_unknown_sells.pop(stock_code, None)
|
||
logger.warning(
|
||
f"[SwingExit] {stock_code}: 결과 불명 매도({pend['qty']}주) 미체결 확인 "
|
||
f"(KIS 보유 {kis_hldg}주 불변) — 대기 해제, 다음 주기 재판정"
|
||
)
|
||
return False
|
||
_pending_unknown_sells.pop(stock_code, None)
|
||
price = float(pend["price"])
|
||
avg = float(pend["avg_price"])
|
||
swing_qty = int(pend["swing_qty"])
|
||
is_full = filled >= swing_qty
|
||
logger.warning(
|
||
f"[SwingExit] {stock_code}: 결과 불명 매도 체결 확인 — {filled}/{pend['qty']}주 "
|
||
f"(KIS 보유 {pend['pre_hldg']}→{kis_hldg}) → 장부 반영"
|
||
)
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": pend["stock_name"],
|
||
"order_type": "SELL",
|
||
"order_price": price,
|
||
"order_quantity": int(pend["qty"]),
|
||
"executed_price": price,
|
||
"executed_quantity": filled,
|
||
"total_amount": price * filled,
|
||
"status": "COMPLETED",
|
||
"order_id": "",
|
||
"exit_reason": f"{pend['reason']} (결과 불명 → KIS 잔고로 체결 확인)",
|
||
"net_profit_loss": (price - avg) * filled,
|
||
"profit_loss_rate": ((price - avg) / avg * 100) if avg > 0 else 0.0,
|
||
"strategy_type": "swing",
|
||
})
|
||
_recent_exits[stock_code] = time.time()
|
||
from app.services.portfolio_cache_bridge import reflect_sell
|
||
await reflect_sell(stock_code, filled, price, remaining_qty=int(kis_hldg))
|
||
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN, enqueue_db_write, enqueue_active_trade_upsert
|
||
if is_full:
|
||
if pend["filter_not_dt"]:
|
||
await enqueue_db_write({"type": "close_active_trade",
|
||
"data": {"stock_code": stock_code, "strategy_name_not": list(_DTN)}})
|
||
else:
|
||
await enqueue_close_active_trade(stock_code, strategy_name=pend["strategy"] or "swing")
|
||
await loss_tracker.record_trade_result_safe(stock_code=stock_code, is_loss=(price - avg) < 0)
|
||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||
_last_live_prices.pop(stock_code, None)
|
||
_last_atr.pop(stock_code, None)
|
||
else:
|
||
from app.services.sizing_core import remaining_pct_after_partial_sell
|
||
new_remaining = remaining_pct_after_partial_sell(
|
||
float(pend["remaining_pct"]), float(pend["sell_pct"]), swing_qty - filled,
|
||
int(pend["original_qty"]),
|
||
)
|
||
data = {"stock_code": stock_code}
|
||
if pend["filter_not_dt"]:
|
||
data["strategy_name_not"] = list(_DTN)
|
||
else:
|
||
data["strategy_name"] = pend["strategy"] or "swing"
|
||
data.update({"remaining_pct": new_remaining, "quantity": swing_qty - filled})
|
||
if not pend["had_active_trade"]:
|
||
data.update({
|
||
"stock_name": pend["stock_name"], "entry_price": avg, "current_price": price,
|
||
"strategy_name": "swing", "status": "active",
|
||
"entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)",
|
||
})
|
||
await enqueue_active_trade_upsert(data)
|
||
return True
|
||
|
||
|
||
# [L-4] 종목별 마지막 실시세(성공 조회값). 현재가 조회 실패 시 손절 판정용 대체 가격.
|
||
# [H-1 2026-10-02] (가격, monotonic 시각, KST wall 시각). 나이 상한·진입 시각 비교·전량 청산 시
|
||
# 폐기를 위해 시각을 함께 저장한다(이전엔 가격만 있어 재매수 포지션에 예전 가격이 쓰였다).
|
||
_last_live_prices: dict[str, tuple] = {}
|
||
|
||
|
||
# [M-4 6회차] 종목별 "현재가·대체가 모두 없음" 연속 skip 횟수
|
||
_no_price_skips: dict[str, int] = {}
|
||
|
||
|
||
async def _alert_no_price_v2(stock_code: str, n: int) -> None:
|
||
"""손절 판정 연속 skip 종목별 텔레그램 시스템 경고. 실패는 warning(판정 루프 무영향)."""
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
await telegram_notifier.notify_system_warning(
|
||
"SWING_EXIT_NO_PRICE",
|
||
f"{stock_code}: 현재가·대체가 없음으로 스윙 손절 판정 {n}회 연속 skip — 수동 확인 필요",
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[SwingExit] {stock_code} 손절 skip 텔레그램 경보 실패: {type(e).__name__}: {e}")
|
||
|
||
|
||
# [H-1 6회차] 직전 정상 지표 경로의 ATR — (atr, monotonic, KST wall). 대체가 손절 판정용.
|
||
_last_atr: dict[str, tuple] = {}
|
||
|
||
|
||
def _remember_atr_v2(stock_code: str, atr) -> None:
|
||
try:
|
||
a = float(atr)
|
||
except (TypeError, ValueError):
|
||
return
|
||
if a > 0:
|
||
from app.core.timeutil import now_kst
|
||
_last_atr[stock_code] = (a, time.monotonic(), now_kst())
|
||
|
||
|
||
def _stale_fallback_stop_pct_v2(stock_code: str, price: float, entry_at) -> float:
|
||
"""현재가 조회 실패(대체가) 시 손절률(음수 %).
|
||
|
||
[H-1 6회차] 대체가 경로는 일봉을 조회하지 않아 ATR 이 None → 고변동 종목도 일반 손절
|
||
(-7%)로 판정돼 고변동 손절(-10%) 전에 오손절됐다.
|
||
- 직전 정상 지표 경로의 ATR 이 유효하면(나이 ≤ SWING_FALLBACK_PRICE_MAX_AGE_SEC,
|
||
진입 이후 값) 그 ATR 로 고변동 여부를 판정한 손절률
|
||
- 없으면 일반·고변동 손절률 중 **가장 느슨한** 값(사용자 결정: 오손절 방지)
|
||
해석 실패 시 바닥값도 config 의 두 손절률 중 느슨한 쪽.
|
||
"""
|
||
_floor = -max(abs(float(settings.STOP_LOSS_PERCENT)), abs(float(settings.STOP_LOSS_HIGH_VOL)))
|
||
atr = None
|
||
cached = _last_atr.get(stock_code)
|
||
if isinstance(cached, tuple) and len(cached) == 3:
|
||
_a, _mono, _wall = cached
|
||
_fresh = (time.monotonic() - _mono) <= float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC)
|
||
try:
|
||
_after_entry = entry_at is None or _wall >= entry_at
|
||
except TypeError:
|
||
_after_entry = False
|
||
if _fresh and _after_entry:
|
||
atr = _a
|
||
try:
|
||
from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2
|
||
if atr is not None:
|
||
sl, _ = resolve_swing_stop_params_v2(atr, price)
|
||
else:
|
||
sl_normal, _ = resolve_swing_stop_params_v2(None, price)
|
||
# ATR/종가 > 3% 를 강제해 고변동 손절률을 얻는다(ATR 값 자체는 쓰지 않음)
|
||
sl_high, _ = resolve_swing_stop_params_v2(price, price)
|
||
sl = min(sl_normal, sl_high)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[SwingExit] 대체가 손절률 해석 실패 ({stock_code}) — 바닥값 {_floor:.1f}% 사용: "
|
||
f"{type(e).__name__}: {e}"
|
||
)
|
||
return _floor
|
||
return -abs(float(sl))
|
||
|
||
|
||
def _remember_live_price_v2(stock_code: str, price: float) -> None:
|
||
from app.core.timeutil import now_kst
|
||
_last_live_prices[stock_code] = (float(price), time.monotonic(), now_kst())
|
||
|
||
|
||
async def _fetch_live_price_v2(stock_code: str, market: str, domestic: bool) -> "float | None":
|
||
"""KIS 실시간 현재가(>0) 또는 None. 조회 예외·응답 없음·빈 값·파싱 불가는 None(+warning)."""
|
||
try:
|
||
if domestic:
|
||
resp = await korea_investment_api.get_current_price(stock_code)
|
||
raw = ((resp or {}).get("output") or {}).get("stck_prpr")
|
||
else:
|
||
resp = await korea_investment_api.get_overseas_price(stock_code, market)
|
||
raw = ((resp or {}).get("output") or {}).get("last")
|
||
if raw is None or str(raw).strip() == "":
|
||
return None
|
||
price = float(raw)
|
||
except Exception as e:
|
||
warn_throttled(
|
||
logger, "exit_service:live_price",
|
||
f"[SwingExit] 현재가 조회 실패 ({stock_code}): {type(e).__name__}: {e}",
|
||
)
|
||
return None
|
||
if price != price or price <= 0:
|
||
return None
|
||
return int(price) if domestic else price
|
||
|
||
|
||
def _fallback_price_v2(stock_code: str, holding) -> "float | None":
|
||
"""하위 호환 위임 — 진입 시각 미지정 `_fallback_price_v3`."""
|
||
return _fallback_price_v3(stock_code, holding, None)
|
||
|
||
|
||
def _fallback_price_v3(stock_code: str, holding, entry_at) -> "float | None":
|
||
"""현재가 조회 실패 시 손절 판정용 대체 가격, 없으면 None.
|
||
|
||
[H-1/M-1 2026-10-02] 후보 = 직전 실시세 캐시 · Portfolio.current_price(updated_at).
|
||
- 나이 > SWING_FALLBACK_PRICE_MAX_AGE_SEC 이면 후보 탈락(캐시는 monotonic, Portfolio 는
|
||
updated_at 기준; updated_at 이 없으면 나이를 모르므로 탈락)
|
||
- entry_at(ActiveTrade.created_at) 이전 시각의 값은 탈락 — 예전 보유분 가격이 재매수
|
||
포지션 손절에 쓰여 시세 조회 1회 실패로 오손절·loss_tracker 오염이 나던 문제
|
||
- 남은 후보 중 **더 최신** 값을 쓴다(우선순위 고정 아님)
|
||
낡은 고가 때문에 익절이 막히는 것은 의도다(대체 가격은 손절 판정에만 쓰인다).
|
||
"""
|
||
from app.core.timeutil import now_kst
|
||
max_age = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC)
|
||
now_wall = now_kst()
|
||
candidates: list[tuple] = [] # (wall 시각, 가격)
|
||
|
||
def _not_before_entry(ts) -> bool:
|
||
if entry_at is None:
|
||
return True
|
||
try:
|
||
return ts >= entry_at
|
||
except TypeError:
|
||
return False # 비교 불가 = 진입 이후임을 보장 못 함 → 탈락
|
||
|
||
cached = _last_live_prices.get(stock_code)
|
||
if isinstance(cached, tuple) and len(cached) == 3:
|
||
_p, _mono, _wall = cached
|
||
if _p and _p > 0 and (time.monotonic() - _mono) <= max_age and _not_before_entry(_wall):
|
||
candidates.append((_wall, float(_p)))
|
||
|
||
try:
|
||
cp = float(getattr(holding, "current_price", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
cp = 0.0
|
||
upd = getattr(holding, "updated_at", None)
|
||
if cp > 0 and upd is not None:
|
||
try:
|
||
_age = (now_wall - upd).total_seconds()
|
||
except TypeError:
|
||
_age = None
|
||
if _age is not None and _age <= max_age and _not_before_entry(upd):
|
||
candidates.append((upd, cp))
|
||
|
||
if not candidates:
|
||
return None
|
||
try:
|
||
return max(candidates, key=lambda c: c[0])[1]
|
||
except TypeError:
|
||
return candidates[0][1]
|
||
|
||
|
||
def _fallback_stop_loss_pct_v2(atr, latest_price: float, stock_code: str = "") -> float:
|
||
"""폴백 손절률(음수 %). 해석 실패 시 config 하드 바닥값 -abs(STOP_LOSS_PERCENT).
|
||
|
||
[C-1 2026-10-02 3회차] 지표 경로와 같은 resolve_swing_stop_params_v2 가 공통 원인으로
|
||
예외를 던져도 폴백 손절은 반드시 판정돼야 한다. 해석만 try 안에 두고, 반환값은
|
||
try 밖에서 음수 부호 가드를 다시 건다(양수가 새면 전 종목 매도 사고).
|
||
"""
|
||
_floor = -abs(float(settings.STOP_LOSS_PERCENT))
|
||
_resolved = None
|
||
try:
|
||
from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2
|
||
_resolved, _ = resolve_swing_stop_params_v2(atr, latest_price)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[SwingExit] 폴백 손절률 해석 실패 ({stock_code}) — config 바닥값 "
|
||
f"{_floor:.1f}% 사용: {type(e).__name__}: {e}"
|
||
)
|
||
_resolved = None
|
||
if _resolved is None:
|
||
return _floor
|
||
return -abs(float(_resolved))
|
||
|
||
|
||
async def _resolve_time_stop_entry_v2(db: AsyncSession, stock_code: str, active_trade):
|
||
"""시간손절 기준 진입일 보정 — 부분매도 시 자동 생성된 ActiveTrade 의 created_at 리셋 보완.
|
||
|
||
[H-3 2026-10-02 3회차] ActiveTrade 가 없던 포지션을 부분매도하면 exit_service 가
|
||
entry_reason="동기화 유입 포지션 (부분매도 시 자동 생성)" 행을 새로 만들어 created_at 이
|
||
**매도일**로 찍힌다 → 시간손절 시계가 0 으로 돌아간다. 그런 행만 TradeHistory 에서
|
||
원래 진입일을 찾는다.
|
||
|
||
[L-3 2026-10-02 4회차] 탐색 범위 = (이 종목의 **마지막 전량 청산** 시각, 행 created_at].
|
||
마지막 전량 청산은 같은 종목 비-DT ActiveTrade 중 status='closed' 행의 최신 updated_at
|
||
(db_worker close_active_trade 가 닫을 때 찍는 시각)이다. 이전엔 하한이 없어 과거에 이미
|
||
정리된 포지션의 BUY 를 진입일로 써서 즉시 시간손절이 날 수 있었다.
|
||
범위 안에서는 **가장 최근** COMPLETED 비-DT BUY 일자를 쓴다(실제 최초 진입보다 늦을 수는
|
||
있어도 이르지는 않다 → 조기 청산 없음, 늦게 발동할 수는 있음).
|
||
범위 안 BUY 가 없거나 조회가 실패하면 기존 created_at 그대로.
|
||
한계: 전량 청산이 ActiveTrade 를 거치지 않은 경로(외부 매도 등)만 있었다면 하한이
|
||
잡히지 않는다 — 그 경우에도 결과는 created_at 이전의 최근 BUY 다.
|
||
반환: created_at 을 덮은 대리 객체 또는 원본.
|
||
SELECT 전용(commit 없음) — CLAUDE.md §3.2 ③-b(스윙 스케줄러 SELECT, 시간손절
|
||
TradeHistory SELECT 포함).
|
||
"""
|
||
if active_trade is None:
|
||
return None
|
||
_reason = getattr(active_trade, "entry_reason", None) or ""
|
||
_created = getattr(active_trade, "created_at", None)
|
||
if not _reason.startswith("동기화 유입") or _created is None:
|
||
return active_trade
|
||
_orig = None
|
||
try:
|
||
from sqlalchemy import func
|
||
from app.models.models import TradeHistory
|
||
from app.services.db_worker import not_dt_strategy_clause
|
||
_closed_res = await db.execute(
|
||
select(func.max(ActiveTrade.updated_at)).where(
|
||
ActiveTrade.stock_code == stock_code,
|
||
ActiveTrade.status == "closed",
|
||
ActiveTrade.updated_at <= _created,
|
||
not_dt_strategy_clause(ActiveTrade.strategy_name),
|
||
)
|
||
)
|
||
_last_full_close = _closed_res.scalar()
|
||
_conds = [
|
||
TradeHistory.stock_code == stock_code,
|
||
TradeHistory.order_type == "BUY",
|
||
TradeHistory.status == "COMPLETED",
|
||
TradeHistory.created_at <= _created,
|
||
not_dt_strategy_clause(TradeHistory.strategy_type),
|
||
]
|
||
if _last_full_close is not None:
|
||
_conds.append(TradeHistory.created_at > _last_full_close)
|
||
_res = await db.execute(select(func.max(TradeHistory.created_at)).where(*_conds))
|
||
_orig = _res.scalar()
|
||
except Exception as e:
|
||
warn_throttled(
|
||
logger, "exit_service:time_stop_entry",
|
||
f"[SwingExit] 시간손절 원 진입일 조회 실패 ({stock_code}) — created_at 사용: {e}",
|
||
)
|
||
_orig = None
|
||
if _orig is None or not hasattr(_orig, "date"):
|
||
return active_trade
|
||
try:
|
||
_is_earlier = _orig < _created
|
||
except TypeError as e: # tz-aware/naive 혼합 등 — 청산 루프를 막지 않는다
|
||
warn_throttled(
|
||
logger, "exit_service:time_stop_entry_cmp",
|
||
f"[SwingExit] 시간손절 원 진입일 비교 실패 ({stock_code}) — created_at 사용: {e}",
|
||
)
|
||
return active_trade
|
||
if not _is_earlier:
|
||
return active_trade
|
||
from types import SimpleNamespace
|
||
return SimpleNamespace(created_at=_orig)
|
||
|
||
|
||
def _time_stop_signal_v2(active_trade, latest_price: float, avg_price: float):
|
||
"""ActiveTrade.created_at(KST naive) 기준 시간손절 신호(ExitSignal) 또는 None.
|
||
|
||
기간·최소수익은 settings.SWING_TIME_STOP_DAYS / SWING_TIME_STOP_MIN_PROFIT_PCT.
|
||
[H-3 2026-10-02 3회차] 보유일은 **KRX 거래일**(주말·휴장일 제외) 기준이다
|
||
(check_time_stop_exit → market_schedule.count_krx_trading_days_between).
|
||
진입일을 알 수 없으면(행 없음·created_at 없음) 판정하지 않는다(None).
|
||
"""
|
||
from app.services.mean_reversion_strategy import ExitSignal, check_time_stop_exit
|
||
from app.core.timeutil import now_kst
|
||
|
||
if active_trade is None or avg_price <= 0:
|
||
return None
|
||
_created = getattr(active_trade, "created_at", None)
|
||
if _created is None:
|
||
return None
|
||
try:
|
||
entry_date = _created.date() if hasattr(_created, "date") else _created
|
||
except Exception as e:
|
||
warn_throttled(logger, "exit_service:time_stop_date", "[SwingExit] 시간손절 진입일 해석 실패: %s", e)
|
||
return None
|
||
profit_pct = (latest_price - avg_price) / avg_price * 100
|
||
_ts = check_time_stop_exit(
|
||
entry_date=entry_date,
|
||
current_date=now_kst().date(),
|
||
profit_pct=profit_pct,
|
||
max_days=int(settings.SWING_TIME_STOP_DAYS),
|
||
min_profit_pct=float(settings.SWING_TIME_STOP_MIN_PROFIT_PCT),
|
||
)
|
||
if not _ts:
|
||
return None
|
||
return ExitSignal(action="SELL_ALL", reason=_ts["reason"], sell_pct=1.0)
|
||
|
||
|
||
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 as e:
|
||
logger.warning(f"[SwingExit] 시장 국면 조회 실패 — BULL 폴백(청산 판정 계속): {type(e).__name__}: {e}")
|
||
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 as e:
|
||
# 국내 매도 직전 KIS 실보유 상한(C-1)이 2차 방어이므로 0 폴백 유지 + warning
|
||
logger.warning(f"[SwingExit] {holding.stock_code} DT 보유분 조회 실패 — 0 으로 간주: {e}")
|
||
_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)
|
||
|
||
# ── 실시간 현재가 조회 ────────────────────────────────────
|
||
# [L-4 2026-10-02 4회차] 조회 예외·빈 문자열(stck_prpr=="")이 종목 전체 try 로
|
||
# 빠져 폴백 손절까지 건너뛰던 것을 막는다. 조회·파싱만 try 안
|
||
# (_fetch_live_price_v2). 실패하면 대체 가격(직전 실시세 → Portfolio 평가가)
|
||
# 으로 **손절 판정만** 하고(익절·트레일링·시간손절은 낡은 가격으로 하지 않음),
|
||
# 대체 가격도 없으면 error 로그(레이트리밋) 후 스킵 — 청산 우선(§8.1-11).
|
||
if avg_price <= 0:
|
||
continue
|
||
# ── ActiveTrade 조회 (분할 청산 상태) ────────────────────
|
||
# [H-1 2026-10-02] 대체 가격의 "진입 이전 값 거부"에 진입 시각이 필요해 현재가
|
||
# 조회 앞으로 옮겼다(조회 자체는 동일).
|
||
active_trade = await get_swing_active_trade(db, stock_code)
|
||
|
||
_live = await _fetch_live_price_v2(stock_code, market, domestic)
|
||
_price_stale = _live is None
|
||
if not _price_stale:
|
||
latest_price = _live
|
||
_remember_live_price_v2(stock_code, _live)
|
||
_no_price_skips.pop(stock_code, None)
|
||
else:
|
||
_entry_at = getattr(active_trade, "created_at", None) if active_trade else None
|
||
latest_price = _fallback_price_v3(stock_code, holding, _entry_at)
|
||
if latest_price is None:
|
||
_key = f"exit_service:no_price:{stock_code}"
|
||
from app.core.log_throttle import should_log
|
||
_ok, _sup = should_log(_key)
|
||
_n_skip = _no_price_skips.get(stock_code, 0) + 1
|
||
_no_price_skips[stock_code] = _n_skip
|
||
_alert_n = max(1, int(settings.SWING_FALLBACK_SKIP_ALERT_AFTER))
|
||
if _n_skip % _alert_n == 0:
|
||
await _alert_no_price_v2(stock_code, _n_skip)
|
||
if _ok:
|
||
logger.error(
|
||
f"[SwingExit] 🚨 {stock_code} 현재가 조회 실패 + 유효한 대체 가격 없음"
|
||
f"(나이 ≤ {settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC:.0f}초·진입 이후 값 없음) — "
|
||
f"손절 판정 skip, 수동 확인 요청"
|
||
+ (f" (+{_sup}건 억제)" if _sup else "")
|
||
)
|
||
continue
|
||
logger.warning(
|
||
f"[SwingExit] {stock_code} 실시간 현재가 조회 실패 — 대체 가격 "
|
||
f"{latest_price:,.2f} 로 손절만 판정"
|
||
)
|
||
|
||
# (ActiveTrade 는 위 현재가 조회 전에 이미 읽었다 — H-1)
|
||
# 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 not _price_stale 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)
|
||
|
||
# ── [C-1(a) 6회차] 결과 불명 매도 대조 ─────────────────────────
|
||
# 신호 유무와 무관하게 매 주기 KIS 실보유로 체결분을 확인해 장부에 반영한다.
|
||
# 반영한 주기는 skip(같은 단계 재발동·부풀린 수량 재매도 방지).
|
||
_kis_snapshot = None
|
||
if domestic and stock_code in _pending_unknown_sells:
|
||
_kis_snapshot = await _presell_kis_holding_v2(stock_code)
|
||
if _kis_snapshot is None:
|
||
continue # 대조 불가 — 이 주기 매도 보류(경고는 헬퍼가 남김)
|
||
if await _reconcile_unknown_sell_v2(stock_code, *_kis_snapshot):
|
||
continue
|
||
|
||
# ── 전략별 청산 분기 ────────────────────────────────────
|
||
exit_signal = None
|
||
_strategy = _at_strategy
|
||
# [H4 2026-10-02] 지표 경로(check_exit_signal/_check_momentum_exit)가 실제로
|
||
# 평가됐는지. 평가됐으면 그 안의 손절이 이미 판정했으므로 폴백을 돌리지 않는다.
|
||
# [C-1 2026-10-02 3회차] True 는 판정 함수가 **정상 반환한 뒤에만** 세운다.
|
||
# 이전엔 호출 전에 세워, 판정 함수가 예외를 던지면 폴백 손절까지 건너뛰어
|
||
# -20% 포지션에도 주문이 0건이었다(손절 공백).
|
||
_indicator_evaluated = False
|
||
_fallback_atr = None
|
||
_ind = None # (atr, rsi, bb, lows) — 판정 가능한 지표가 모였을 때만 채운다
|
||
|
||
if domestic and not _price_stale:
|
||
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)
|
||
_fallback_atr = atr
|
||
_remember_atr_v2(stock_code, atr)
|
||
|
||
if atr and rsi is not None and bb:
|
||
_ind = (atr, rsi, bb, lows)
|
||
except Exception as e:
|
||
warn_throttled(
|
||
logger, "exit_service:indicator_fetch",
|
||
f"[SwingExit] 일봉/지표 산출 실패 ({stock_code}) — 폴백 손절로 판정: {e}",
|
||
)
|
||
|
||
if _ind is not None:
|
||
atr, rsi, bb, lows = _ind
|
||
try:
|
||
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,
|
||
)
|
||
_indicator_evaluated = True # 정상 반환한 경우에만
|
||
except Exception as e:
|
||
exit_signal = None
|
||
# 청산 판정 실패는 손절 공백으로 직결되므로 스로틀하지 않는다.
|
||
logger.warning(
|
||
f"[SwingExit] 청산 판정 함수 예외 ({stock_code}, {_strategy}) — "
|
||
f"폴백 손절로 판정: {type(e).__name__}: {e}",
|
||
exc_info=True,
|
||
)
|
||
|
||
# ── 시간손절 (CLAUDE.md §6.3) ─────────────────────────────
|
||
# [2026-10-02] check_time_stop_exit 는 호출부가 없었다. 평균회귀·눌림목
|
||
# 계열(모멘텀 제외 — 자체 단계 익절/트레일링)에만 배선하고
|
||
# SWING_TIME_STOP_ENABLED(기본 True)로 게이트한다. 전량 신호가 이미
|
||
# 있으면 그대로 두고, 없거나 부분매도면 시간손절(전량)이 우선한다.
|
||
if (
|
||
settings.SWING_TIME_STOP_ENABLED
|
||
and not _price_stale
|
||
and _strategy != "MOMENTUM_BREAKOUT"
|
||
and (exit_signal is None or getattr(exit_signal, "sell_pct", 1.0) < 1.0)
|
||
):
|
||
# 시간손절 판정 예외가 아래 폴백 손절까지 끌고 내려가지 않도록 격리한다
|
||
# (예외 시 기존 exit_signal 유지 — 청산 우선 §8.1-11).
|
||
_ts_signal = None
|
||
try:
|
||
_ts_basis = await _resolve_time_stop_entry_v2(db, stock_code, active_trade)
|
||
_ts_signal = _time_stop_signal_v2(_ts_basis, latest_price, avg_price)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[SwingExit] 시간손절 판정 예외 ({stock_code}) — 건너뜀: "
|
||
f"{type(e).__name__}: {e}"
|
||
)
|
||
if _ts_signal is not None:
|
||
exit_signal = _ts_signal
|
||
|
||
# ── 고정 비율 폴백 손절 ───────────────────────────────────
|
||
# [H4 2026-10-02] 지표 경로가 **판정 불가**(해외·차트<20봉·ATR/RSI/BB 산출
|
||
# 실패·조회 예외)일 때만 돈다. 이전엔 지표 경로가 평가 후 "청산 없음"을 내도
|
||
# STOP_LOSS_PERCENT(7%) 고정으로 다시 판정해, 고변동 종목(-10%)을 지표 경로보다
|
||
# 먼저 손절했다. 손절률은 check_exit_signal 과 같은 소스
|
||
# (resolve_swing_stop_params_v2: 적응형 → strategy_cfg → config, ATR 을 알면 고변동 포함).
|
||
# [C-1 2026-10-02 3회차] 폴백이 지표 경로와 같은 resolve_swing_stop_params_v2 를
|
||
# 쓰므로 공통 원인(비정상 strategy_cfg 값·None 적응형 필드)으로 함께 실패할 수
|
||
# 있다. 해석만 try 안에 두고, 실패하면 config 하드 바닥값
|
||
# -abs(STOP_LOSS_PERCENT) 로 판정한다. 판정 자체는 try 밖(§4·§8.1-11).
|
||
if exit_signal is None and not _indicator_evaluated:
|
||
if _price_stale:
|
||
# [H-1 6회차] 대체가 판정은 직전 정상 지표 경로의 ATR(고변동 여부)로,
|
||
# 없으면 가장 느슨한 설정 손절률로만 판정한다(오손절 방지).
|
||
_fb_sl_pct = _stale_fallback_stop_pct_v2(
|
||
stock_code, latest_price,
|
||
getattr(active_trade, "created_at", None) if active_trade else None,
|
||
)
|
||
else:
|
||
_fb_sl_pct = _fallback_stop_loss_pct_v2(_fallback_atr, latest_price, stock_code)
|
||
profit_rate = ((latest_price - avg_price) / avg_price) * 100
|
||
if profit_rate <= _fb_sl_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
|
||
|
||
# ── [C-1 6회차] 국내 매도 직전 KIS 실보유로 수량 상한 ────────────────
|
||
# 손절·익절·시간손절·폴백 모든 국내 시장가 매도: 상한 = min(주문가능, 보유) − DT
|
||
# 인메모리 보유분. Portfolio 수량(sync 전 낡은 값)이 부풀어 있어도 DT 물량은
|
||
# 팔 수 없다. 조회 실패면 보류, 상한 0 이면 주문하지 않는다.
|
||
_pre_hldg = None
|
||
if domestic:
|
||
if _kis_snapshot is None:
|
||
_kis_snapshot = await _presell_kis_holding_v2(stock_code)
|
||
if _kis_snapshot is None:
|
||
continue
|
||
_kis_hldg, _kis_sellable = _kis_snapshot
|
||
_cap = max(0, min(_kis_sellable, _kis_hldg) - max(0, int(_dt_qty or 0)))
|
||
if _cap <= 0:
|
||
logger.warning(
|
||
f"[SwingExit] {stock_code}: KIS 기준 스윙 매도 가능 0 (보유 {_kis_hldg}, "
|
||
f"주문가능 {_kis_sellable}, DT {_dt_qty}) — 주문 안 함"
|
||
)
|
||
continue
|
||
if _cap < quantity:
|
||
logger.warning(
|
||
f"[SwingExit] {stock_code}: 장부 스윙 {quantity}주 > KIS 기준 {_cap}주 — "
|
||
f"KIS 기준으로 상한"
|
||
)
|
||
quantity = _cap
|
||
total_qty = _kis_hldg
|
||
if sell_qty > quantity:
|
||
sell_qty = quantity
|
||
_pre_hldg = _kis_hldg
|
||
|
||
# [M-4 2026-10-02] 주문 전송 ~ TradeHistory·Portfolio·ActiveTrade·loss_tracker 반영
|
||
# 전체를 asyncio.shield 로 보호한다(DT _execute_exit_impl 과 같은 패턴). 스케줄러
|
||
# 종료·취소가 safe_place_order 대기 중에 들어오면 "브로커는 체결, 장부는 미반영" →
|
||
# 다음 기동 후 같은 물량 재매도가 날 수 있었다. 취소는 이 루프에만 전파되고
|
||
# shield 된 작업은 끝까지 진행된다.
|
||
async def _sell_and_record_v2():
|
||
# ── 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",
|
||
)
|
||
|
||
# [M-2 2026-10-02 5회차] 실패를 msg_cd 로 분류한다. 결과 불명(전송 후 응답 유실)을
|
||
# 거부와 똑같이 continue 하면, 60초 뒤 sync 전 낡은 Portfolio 수량 − DT 로
|
||
# 재매도해 (체결됐을 경우) DT 공존 종목에서 과다매도가 났다.
|
||
# - unknown: 확인 대기 등록 → 다음 재매도 전 KIS 잔고로 수량 상한 재산정
|
||
# - retry : 전송 전 실패·업무 거부 — 기존처럼 다음 주기 재시도
|
||
_outcome = _classify_swing_sell_response_v2(order_resp)
|
||
if _outcome != "accepted":
|
||
err = (order_resp or {}).get("msg1", "unknown") if isinstance(order_resp, dict) else "no response"
|
||
_mc = (order_resp or {}).get("msg_cd") if isinstance(order_resp, dict) else None
|
||
if _outcome == "unknown" and not domestic:
|
||
# 해외 잔고 확인 경로는 아직 없다(US_TRADING_ENABLED=False). 기존 동작 유지.
|
||
logger.error(
|
||
f"[SwingExit] 🚨 해외 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): "
|
||
f"{err} — 해외 잔고 확인 미지원, 수동 확인 필요"
|
||
)
|
||
elif _outcome == "unknown":
|
||
_pending_unknown_sells[stock_code] = {
|
||
"qty": int(sell_qty),
|
||
"pre_hldg": int(_pre_hldg if _pre_hldg is not None else total_qty),
|
||
"since": time.time(),
|
||
"price": float(latest_price),
|
||
"avg_price": float(avg_price),
|
||
"swing_qty": int(quantity),
|
||
"sell_pct": float(sell_pct),
|
||
"remaining_pct": float(remaining_pct),
|
||
"original_qty": int(original_qty),
|
||
"strategy": _strategy,
|
||
"filter_not_dt": bool(_at_filter_not_dt),
|
||
"had_active_trade": active_trade is not None,
|
||
"stock_name": stock_name,
|
||
"reason": exit_signal.reason,
|
||
}
|
||
_trigger_portfolio_sync_v2()
|
||
logger.error(
|
||
f"[SwingExit] 🚨 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): "
|
||
f"{err} — 확인 대기 등록, 다음 주기 KIS 잔고로 체결분 대조·장부 반영"
|
||
)
|
||
else:
|
||
logger.error(
|
||
f"[SwingExit] 매도 주문 실패 ({stock_code}, msg_cd={_mc}): {err} — 다음 주기 재시도"
|
||
)
|
||
return
|
||
|
||
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 갱신 (반복 트리거 방지)
|
||
# [H1 2026-10-02] 잔량 비율만 기록하면 홀수 수량에서 내림 매도 후
|
||
# 비율이 게이트(>0.50 / >0.75) 위에 남아 쿨다운 뒤 같은 단계가
|
||
# 재발동했다(7주→1주, 5주→전량). 단계값(이전 − sell_pct)을 상한으로
|
||
# 기록한다. 매도 주수 자체는 위의 내림 공식 그대로(과매도 방지).
|
||
from app.services.sizing_core import remaining_pct_after_partial_sell
|
||
new_remaining = remaining_pct_after_partial_sell(
|
||
remaining_pct, sell_pct, quantity - sell_qty, original_qty
|
||
)
|
||
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:
|
||
# [H-1 2026-10-02] 전량 청산 → 이 포지션의 실시세 캐시 폐기
|
||
# (재매수 포지션에 예전 보유분 가격이 대체가로 쓰이지 않게)
|
||
_last_live_prices.pop(stock_code, None)
|
||
_last_atr.pop(stock_code, None)
|
||
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}"
|
||
)
|
||
|
||
await asyncio.shield(_sell_and_record_v2())
|
||
|
||
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
|