Files
auto-stock/backend/app/services/swing/exit_service.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

2565 lines
139 KiB
Python
Raw Permalink 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 (하드스탑과 비교하여 타이트한 쪽)
- 고정비율 폴백 손절: 지표 판정 불가 시. 손절률은 resolve_swing_stop_params_v2 — ATR 을 모르면
신선한 캐시 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, resolve_swing_entry_price_v2,
)
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 _exit_cooldown_applies_v2(exit_signal, domestic: bool) -> bool:
"""[하드닝 R8 2026-10-10] 청산 쿨다운(EXIT_COOLDOWN_SEC)을 이 신호에 적용할지.
쿨다운의 목적은 큐 지연으로 같은 '분할' 단계가 반복 매도되는 것을 막는 것이다.
국내 전량형 신호(sell_pct >= 1.0: 손절·시간손절·폴백 손절·전량 익절)는 적용하지 않는다 —
청산 우선(§8.1-11)이며 과다매도는 매도 직전 KIS 실보유 상한이 막는다.
해외는 실보유 상한이 없으므로 모든 신호에 적용한다. sell_pct 를 해석할 수 없으면
전량(1.0)으로 본다(손절 보류를 피하는 쪽 — 국내 수량은 KIS 상한이 제한).
"""
if not domestic:
return True
try:
_sp = float(getattr(exit_signal, "sell_pct", 1.0))
except (TypeError, ValueError):
_sp = 1.0
if _sp != _sp: # NaN
_sp = 1.0
return _sp < 1.0
# ─────────────────────────────────────────────────────────────────────────────
# [하드닝 R4 2026-10-09] 청산 판정 결과 집계 · 종목별 연속 예외 · 매도 거부 감시
#
# (1) 이전에는 바깥 except("전체 오류")와 종목별 except 가 로그만 남기고 정상 반환했다.
# 스케줄러는 예외를 받지 못해 매 사이클 _record_exit_success_v2 를 호출 → 연속 실패
# 경보·무완료(stale) 경보가 영구 미발동했다(DB 풀 고갈 등으로 손절이 몇 시간 멈춰도 무경보).
# 이제 매 실행 결과(ok/오류)를 _exit_run_state_v2 에 남기고, 스케줄러가 실행 일련번호
# (seq)로 이번 실행분을 확인해 실패로 집계한다. 실패 = 전체 오류 OR 보유 종목 전부 예외.
# 일부 종목만 예외면 실행은 성공이지만 종목별 연속 예외 카운터가 임계 도달 시 경보한다.
# (2) KIS 업무거부·전송 전 실패로 매도가 안 나간 경우(outcome=retry)가 매 60초 같은 거부를
# 반복해도 흔적이 없었다. 종목별 연속 거부를 세고 임계 도달 시 텔레그램, 계좌 단위 거부
# (KIS_ACCOUNT_REJECT_MSG_CODES, 예: 40910000 계좌 교체)는 1회째에 즉시 텔레그램.
# 둘 다 get_swing_exit_health_v2()["degraded_reasons"] 로 /health 에 노출된다.
# 카운터 갱신은 try 밖(가드), 경보 전송 실패는 warning 으로만 남기고 청산 흐름을 막지 않는다.
# ─────────────────────────────────────────────────────────────────────────────
_DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT = 3
_DEFAULT_SELL_REJECT_ALERT_COUNT = 3
_exit_run_state_v2: dict = {
"seq": 0, # 실행 완료 일련번호(스케줄러가 이번 실행분인지 판정)
"ok": True,
"error": None, # 실패 사유(문자열)
"holdings": 0,
"errored": 0, # 이번 실행에서 예외가 난 종목 수
"errors": [], # "종목: 예외" 목록(최대 10개)
}
_swing_exit_error_counts: dict[str, int] = {} # 종목 -> 연속 판정 예외 횟수
_swing_sell_reject_counts: dict[str, int] = {} # 종목 -> 연속 매도 거부(미전송 포함) 횟수
_swing_sell_reject_last: dict[str, dict] = {} # 종목 -> {"msg_cd","msg","account"}
def _exit_alert_threshold_v2(name: str, default: int) -> int:
"""settings 정수 임계(1 이상). 누락·비정상이면 기본값 — 경보를 끄는 값은 허용하지 않는다."""
raw = getattr(settings, name, None)
try:
val = int(raw) if raw is not None else int(default)
except (TypeError, ValueError):
logger.warning(f"[SwingExit] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용")
return int(default)
if val < 1:
logger.warning(f"[SwingExit] 설정 {name}={raw!r} < 1 — 기본값 {default} 사용")
return int(default)
return val
def _notify_swing_exit_alert_v2(key: str, category: str, message: str) -> None:
"""스윙 스케줄러의 텔레그램 경보(_notify_throttled_v2, key 별 재전송 억제)로 보낸다.
지연 import — scheduler 가 이 모듈을 import 하므로 순환을 피한다. 실패는 warning(§4).
"""
try:
from app.services.swing.scheduler import _notify_throttled_v2
_notify_throttled_v2(key, category, message)
except Exception as e:
logger.warning(f"[SwingExit] 텔레그램 경보 예약 실패({key}): {type(e).__name__}: {e}")
def _account_reject_codes_v2() -> set:
try:
return {str(c) for c in (getattr(settings, "KIS_ACCOUNT_REJECT_MSG_CODES", None) or ())}
except TypeError:
logger.warning("[SwingExit] KIS_ACCOUNT_REJECT_MSG_CODES 비정상 — 계좌 거부 즉시 경보 비활성")
return set()
def _note_swing_sell_reject_v2(stock_code: str, msg_cd, err) -> int:
"""매도 미전송·업무거부 1회 기록 + 임계/계좌거부 경보. 반환: 연속 횟수."""
n = _swing_sell_reject_counts.get(stock_code, 0) + 1
_swing_sell_reject_counts[stock_code] = n
_mc = str(msg_cd or "")
is_account = bool(_mc) and _mc in _account_reject_codes_v2()
_swing_sell_reject_last[stock_code] = {"msg_cd": _mc or None, "msg": str(err), "account": is_account}
if is_account:
msg = (
f"스윙 매도 계좌 단위 거부(msg_cd={_mc}) — {stock_code} 손절·익절 미체결({n}회 연속). "
f"계좌/앱키 교체 여부 확인 필요(키 3개 교체 + compose 재생성): {err}"
)
logger.error(f"[SwingExit] 🚨 {msg}")
_notify_swing_exit_alert_v2("sell_reject_account", "SWING_EXIT", msg)
return n
threshold = _exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", _DEFAULT_SELL_REJECT_ALERT_COUNT)
if n >= threshold:
msg = (
f"스윙 매도 연속 실패 {n}회 ({stock_code}, msg_cd={_mc or None}) — "
f"청산 신호가 체결되지 않고 있음(거래정지·주문가능수량 부족·미체결 잔존 등 확인): {err}"
)
logger.error(f"[SwingExit] 🚨 {msg}")
_notify_swing_exit_alert_v2(f"sell_reject:{stock_code}", "SWING_EXIT", msg)
return n
def _clear_swing_sell_reject_v2(stock_code: str) -> None:
n = _swing_sell_reject_counts.pop(stock_code, 0)
_swing_sell_reject_last.pop(stock_code, None)
if n:
logger.warning(f"[SwingExit] {stock_code} 매도 접수 정상화 (연속 실패 {n}회 후)")
def _note_exit_symbol_error_v2(stock_code: str, err: str) -> int:
n = _swing_exit_error_counts.get(stock_code, 0) + 1
_swing_exit_error_counts[stock_code] = n
threshold = _exit_alert_threshold_v2(
"SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", _DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT
)
if n >= threshold:
msg = f"스윙 청산 판정 종목 오류 {n}회 연속 ({stock_code}) — 이 종목 손절·익절이 멈춰 있음: {err}"
logger.error(f"[SwingExit] 🚨 {msg}")
_notify_swing_exit_alert_v2(f"exit_symbol_error:{stock_code}", "SWING_EXIT", msg)
return n
def _record_exit_run_v2(holding_codes: list, errored: dict, fatal: "str | None") -> None:
"""이번 실행 결과 기록. 종목별 연속 예외 카운터 갱신(예외 없던 보유 종목은 0으로).
보유 목록에서 사라진 종목의 카운터는 정리한다(매도 거부 카운터 포함). 전체 오류(fatal)면
보유 목록을 모르므로 카운터는 건드리지 않는다.
"""
if fatal is None:
held = set(holding_codes)
for code in list(_swing_exit_error_counts):
if code not in errored:
_swing_exit_error_counts.pop(code, None)
for code in list(_swing_sell_reject_counts):
if code not in held:
_swing_sell_reject_counts.pop(code, None)
_swing_sell_reject_last.pop(code, None)
n_hold = len(holding_codes)
all_failed = n_hold > 0 and len(errored) >= n_hold
ok = fatal is None and not all_failed
if fatal is not None:
error = f"전체 오류: {fatal}"
elif all_failed:
first = next(iter(errored.items()))
error = f"보유 {n_hold}종목 전부 판정 예외 (예: {first[0]}: {first[1]})"
else:
error = None
_exit_run_state_v2.update({
"seq": _exit_run_state_v2["seq"] + 1,
"ok": ok,
"error": error,
"holdings": n_hold,
"errored": len(errored),
"errors": [f"{c}: {e}" for c, e in list(errored.items())[:10]],
})
def get_last_exit_run_status_v2() -> dict:
"""마지막 check_and_execute_exit_v2 실행 결과(복사본). seq 로 실행분을 구분한다."""
st = dict(_exit_run_state_v2)
st["errors"] = list(st["errors"])
return st
async def check_and_execute_exit_v2_impl(db: AsyncSession) -> "tuple[list[dict], bool, list[str]]":
"""check_and_execute_exit_v2 + 실행 결과. 반환 (exits, ok, errors).
ok=False — 전체 오류 또는 보유 종목 전부 예외. errors — 실패 사유·종목별 예외 목록.
"""
exits = await check_and_execute_exit_v2(db)
st = get_last_exit_run_status_v2()
errors = ([st["error"]] if st["error"] else []) + st["errors"]
return exits, bool(st["ok"]), errors
def get_swing_exit_health_v2() -> dict:
"""청산 판정·매도 거부 감시 상태(읽기 전용) — 스케줄러 /health 에 합쳐진다."""
sym_th = _exit_alert_threshold_v2(
"SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", _DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT
)
rej_th = _exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", _DEFAULT_SELL_REJECT_ALERT_COUNT)
reasons: list = []
if not _exit_run_state_v2["ok"]:
reasons.append("exit_run_failed")
if any(n >= sym_th for n in _swing_exit_error_counts.values()):
reasons.append("exit_symbol_errors")
if any(n >= rej_th for n in _swing_sell_reject_counts.values()):
reasons.append("swing_sell_rejects")
if any(v.get("account") for v in _swing_sell_reject_last.values()):
reasons.append("swing_sell_account_reject")
# [하드닝 R1-b 2026-10-09] 청산 판정 대상 밖 보유(무경보 휴면) · 잔고 첫 페이지 포화 근접
reasons.extend(_census_degraded_reasons_v2())
return {
"last_run": get_last_exit_run_status_v2(),
"symbol_error_counts": dict(_swing_exit_error_counts),
"sell_reject_counts": dict(_swing_sell_reject_counts),
"sell_reject_last": {k: dict(v) for k, v in _swing_sell_reject_last.items()},
"empty_holdings_streak": int(_empty_holdings_streak),
"unowned_streak": int(_unowned_streak),
"census": dict(_census_state_v2),
"degraded_reasons": reasons,
}
# ─────────────────────────────────────────────────────────────────────────────
# [하드닝 R1-b 2026-10-09] 청산 루프 휴면·잔고 페이지 포화 감시
#
# (1) 2026-05-15~09-21 4개월 휴면: get_swing_holdings 가 빈 목록을 반환해도 실행은 '성공'으로
# 기록되고 info/warning 로그뿐이었다. 이제 매 주기 Portfolio(qty>0)를 함께 SELECT 해
# **어느 엔진의 태그도 아닌 행**(strategy_type NULL·미상)이 있으면 연속 주기를 세고, 임계
# (SWING_EXIT_UNOWNED_ALERT_CYCLES, 기본 5) 도달 시 error + 텔레그램 + /health degraded
# ('exit_zero_eval' = 스윙 판정 0종목인데 미소유 보유 존재 / 'exit_unowned_holdings').
# daytrading 태그 고아(DT 인메모리 0)는 position_manager.detect_strategy_tag_mismatch_v2 소관.
# (2) get_balance 는 첫 페이지(모의 20행)만 본다(§8.5-37). 국내 보유 행 수가
# SWING_BALANCE_PAGE_WARN_ROWS(미설정 시 SWING_BALANCE_FULL_PAGE_ROWS − 4) 이상이면
# 손절 보류 절벽에 근접 — error + 텔레그램 + 'balance_page_near_full'.
# 경보 전용이라 실패해도 청산 판정을 막지 않는다(조회만 try, 카운터는 결과가 있을 때만 갱신).
# ─────────────────────────────────────────────────────────────────────────────
_SWING_EVAL_KNOWN_TAGS = ("swing", "daytrading", "external", "manual", "us_swing")
_DEFAULT_EXIT_UNOWNED_ALERT_CYCLES = 5
_BALANCE_PAGE_WARN_MARGIN = 4
_unowned_streak: int = 0
_census_state_v2: dict = {}
def _balance_page_warn_rows_v2() -> int:
raw = getattr(settings, "SWING_BALANCE_PAGE_WARN_ROWS", None)
if raw is not None:
return _exit_alert_threshold_v2("SWING_BALANCE_PAGE_WARN_ROWS", 16)
try:
full = int(settings.SWING_BALANCE_FULL_PAGE_ROWS)
except (TypeError, ValueError, AttributeError):
logger.warning("[SwingExit] SWING_BALANCE_FULL_PAGE_ROWS 비정상 — 페이지 포화 경보 임계 16 사용")
return 16
return max(1, full - _BALANCE_PAGE_WARN_MARGIN)
async def _portfolio_census_v2(db: AsyncSession) -> "dict | None":
"""Portfolio(qty>0) 태그 집계. 조회 실패면 None(+warning).
DB-READ-EXCEPTION: SELECT 전용(commit·add 없음) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session.
"""
try:
res = await db.execute(
select(Portfolio.stock_code, Portfolio.strategy_type).where(Portfolio.quantity > 0)
)
rows = [(str(r[0] or ""), r[1]) for r in res.all()]
unowned = sorted(
c for c, t in rows if t is None or str(t) not in _SWING_EVAL_KNOWN_TAGS
)
domestic_rows = sum(1 for c, _ in rows if c and _is_domestic(c))
except Exception as e:
warn_throttled(
logger, "exit_service:census",
f"[SwingExit] Portfolio 보유 집계 실패(휴면·페이지 감시 이번 주기 생략): {type(e).__name__}: {e}",
)
return None
return {"rows": len(rows), "domestic_rows": domestic_rows, "unowned": unowned}
def _apply_census_v2(census: "dict | None", n_holdings: int) -> None:
"""집계 결과로 연속 카운터·경보 갱신. census None(조회 실패)이면 상태 유지."""
global _unowned_streak
if census is None:
return
_census_state_v2.clear()
_census_state_v2.update({**census, "unowned": list(census["unowned"]), "holdings": int(n_holdings)})
from app.core.log_throttle import should_log
unowned = census["unowned"]
if unowned:
_unowned_streak += 1
th = _exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", _DEFAULT_EXIT_UNOWNED_ALERT_CYCLES)
if _unowned_streak >= th:
msg = (
f"스윙 청산 판정 대상 밖 보유 {len(unowned)}종목 {unowned[:5]} "
f"(Portfolio.strategy_type 미지정/미상, {_unowned_streak}주기 연속) — 스윙 판정 "
f"{n_holdings}종목만 진행 중, 위 종목은 손절이 걸려 있지 않음. 소유 태그 확인 필요"
)
_ok, _sup = should_log("exit_service:unowned_holdings")
if _ok:
logger.error(f"[SwingExit] 🚨 {msg}" + (f" (+{_sup}건 억제)" if _sup else ""))
_notify_swing_exit_alert_v2(
"exit_zero_eval" if n_holdings == 0 else "exit_unowned_holdings", "SWING_EXIT", msg,
)
else:
_unowned_streak = 0
warn_rows = _balance_page_warn_rows_v2()
if int(census["domestic_rows"]) >= warn_rows:
msg = (
f"국내 보유 {census['domestic_rows']}행 ≥ {warn_rows} — KIS 잔고 첫 페이지"
f"({settings.SWING_BALANCE_FULL_PAGE_ROWS}행) 포화 근접. 넘으면 2페이지 종목의 스윙 손절이 "
f"보류된다(get_balance 연속조회 미구현, CLAUDE.md §8.5-37)"
)
_ok, _sup = should_log("exit_service:balance_page_near_full")
if _ok:
logger.error(f"[SwingExit] 🚨 {msg}" + (f" (+{_sup}건 억제)" if _sup else ""))
_notify_swing_exit_alert_v2("balance_page_near_full", "SWING_EXIT", msg)
def _census_degraded_reasons_v2() -> list:
reasons: list = []
th = _exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", _DEFAULT_EXIT_UNOWNED_ALERT_CYCLES)
if _unowned_streak >= th:
reasons.append(
"exit_zero_eval" if int(_census_state_v2.get("holdings", 0) or 0) == 0
else "exit_unowned_holdings"
)
try:
if int(_census_state_v2.get("domestic_rows", 0) or 0) >= _balance_page_warn_rows_v2():
reasons.append("balance_page_near_full")
except (TypeError, ValueError):
reasons.append("census_unreadable")
return reasons
def _effective_exit_reason_v2(
reason: str, signal_sell_pct: float, sell_qty: int, swing_basis: int, original_qty: int,
) -> str:
"""기록용 청산 사유 — 신호 사유를 실제 매도 수량에 맞춘다(§4 설명 문자열 = 실효값).
[하드닝 R1-b 2026-10-09] 3주 이하 소량·KIS 상한으로 매도 수량이 신호와 달라져도
check_exit_signal 의 '1차 익절(50%)' 이 그대로 저장돼 저널·ML 학습·분할/전량 집계가 오염됐다
(id330 KT&G 2주, id413 아모레퍼시픽 3주, id385 LG화학 1주 — 전부 전량 매도).
접두어에는 '손절'·'익절'·'트레일링' 등 분류 키워드를 넣지 않는다(trade_analyzer._parse_exit_type
분류는 원 사유가 결정).
"""
# 기록 문자열 가공 실패가 매도를 막지 않도록 원 사유로 폴백한다(청산 우선 §8.1-11).
try:
sp = float(signal_sell_pct)
sq, sb = int(sell_qty), int(swing_basis)
base = int(original_qty) if int(original_qty or 0) > 0 else sb
actual = (f"{sq / base:.0%}" if base > 0 else "?")
if sp < 1.0 and sq >= sb:
return (
f"소량 전량매도(분할 신호 {sp:.0%} → 실제 {sq}/{sb}주 전량, "
f"원본 대비 {actual}): {reason}"
)
if sp >= 1.0 and sq < sb:
return f"KIS 상한 부분매도(전량 신호 → 실제 {sq}/{sb}주): {reason}"
except Exception as e:
logger.warning(f"[SwingExit] 청산 사유 가공 실패 — 원 사유 기록: {type(e).__name__}: {e}")
return str(reason)
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:
# [R3 2026-10-09] 리터럴 1.5 → 평균회귀와 같은 단일 출처(기본값 동일 1.5)
from app.services.mean_reversion_strategy import resolve_swing_trailing_atr_mult_v2
_trail_mult = resolve_swing_trailing_atr_mult_v2()
trail_stop = trailing_high - _trail_mult * atr
if current_price <= trail_stop and profit_pct > 0:
return ExitSignal(
action="SELL_ALL",
reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f}=고점-{_trail_mult:g}×ATR)",
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] = {}
# ── [하드닝 R2 2026-10-09] 결과 불명 매도 대기 영속화 (Redis 당일 해시) ─────────────────
# 대기가 인메모리 전용이라 재기동하면 체결분의 SELL TradeHistory·remaining_pct·전량 close·
# loss_tracker 가 영영 반영되지 않고, remaining_pct=1.0 이 남아 같은 분할 단계가 재발동할 수
# 있었다. 등록·해제마다 Redis 해시(당일 KST 키)에 미러링하고, 기동 후 첫 청산 주기에 복원해
# 기존 KIS 잔고 대조(_reconcile_unknown_sell_v2)로 흘려보낸다.
# - Redis 미연결(클라이언트 None): 이 프로세스에서는 영속화·복원 자체가 불가능하고 재시도도
# 의미가 없다 → warning 후 보류 없이 진행(기존 동작).
# - Redis 조회 예외(일시 장애): 복원 실패 → 그 주기 국내 **분할(부분) 익절 매도만** 보류하고
# 다음 주기 재시도. _UNKNOWN_SELL_RESTORE_HOLD_CYCLES 주기 보류 후에도 실패면 error 후 진행.
# 손절·전량 청산은 보류하지 않는다(청산 우선, §8.1-11 — 수량 상한은 매도 직전 KIS 실보유).
_UNKNOWN_SELL_REDIS_KEY_PREFIX = "swing:unknown_sells:"
_UNKNOWN_SELL_REDIS_TTL_SEC = 2 * 86400
_UNKNOWN_SELL_RESTORE_HOLD_CYCLES = 1
# "pending" → 복원 미시도/재시도 대기, "ok" → 복원 완료(또는 Redis 미연결로 복원 불가 확정),
# "gave_up" → 보류 한도 초과로 복원 포기
_unknown_sells_restore_state: str = "pending"
_unknown_sells_restore_failures: int = 0
_UNKNOWN_SELL_REQUIRED_KEYS_V2 = (
"qty", "pre_hldg", "price", "avg_price", "swing_qty", "sell_pct", "remaining_pct",
"original_qty", "filter_not_dt", "had_active_trade", "stock_name", "reason",
)
def _unknown_sells_redis_key_v2() -> str:
from app.core.timeutil import today_kst
return f"{_UNKNOWN_SELL_REDIS_KEY_PREFIX}{today_kst():%Y%m%d}"
async def _persist_unknown_sell_v2(stock_code: str, entry: dict) -> bool:
"""대기 1건을 Redis 에 미러링. 실패는 warning(인메모리 대기는 그대로 유효)."""
from app.core.redis import get_redis
client = get_redis()
if client is None:
logger.warning(
f"[SwingExit] {stock_code}: Redis 미연결 — 결과 불명 매도 대기 영속화 불가 "
"(재기동 시 체결분 장부 반영 누락 가능, 수동 확인 필요)"
)
return False
try:
key = _unknown_sells_redis_key_v2()
await client.hset(key, stock_code, _dumps(entry, default=float))
await client.expire(key, _UNKNOWN_SELL_REDIS_TTL_SEC)
return True
except Exception as e:
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 매도 대기 Redis 저장 실패 — {type(e).__name__}: {e} "
"(재기동 시 체결분 장부 반영 누락 가능)"
)
return False
async def _forget_unknown_sell_v2(stock_code: str) -> None:
"""대기 해제를 Redis 에 반영. 실패는 warning — 남은 항목은 복원 후 KIS 대조로 다시 해제된다."""
from app.core.redis import get_redis
client = get_redis()
if client is None:
return
try:
await client.hdel(_unknown_sells_redis_key_v2(), stock_code)
except Exception as e:
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 매도 대기 Redis 해제 실패 — {type(e).__name__}: {e}"
)
def _parse_restored_unknown_sell_v2(stock_code: str, raw) -> "dict | None":
import json
try:
entry = json.loads(raw)
if not isinstance(entry, dict) or any(k not in entry for k in _UNKNOWN_SELL_REQUIRED_KEYS_V2):
raise ValueError("필수 키 누락")
for k in ("qty", "pre_hldg", "swing_qty", "original_qty"):
entry[k] = int(entry[k])
for k in ("price", "avg_price", "sell_pct", "remaining_pct"):
entry[k] = float(entry[k])
if entry.get("prior_realized_net") is not None:
entry["prior_realized_net"] = float(entry["prior_realized_net"])
if entry["qty"] <= 0:
raise ValueError("qty<=0")
# [R9] monotonic 시각은 프로세스 간 비교 불가 — 복원 대기는 "이 프로세스의 모든 매수보다 먼저 등록"으로 본다
entry.pop("since_mono", None)
return entry
except (TypeError, ValueError) as e:
logger.error(
f"[SwingExit] 🚨 {stock_code}: Redis 결과 불명 매도 대기 형식 이상 — 복원 불가({e}), 수동 확인 필요"
)
return None
async def _restore_unknown_sells_v2() -> bool:
"""기동 후 Redis 에서 결과 불명 매도 대기를 복원. 반환 = 이 주기 분할 익절 매도를 보류할지.
인메모리에 이미 있는 종목은 덮어쓰지 않는다(이 프로세스의 등록이 최신).
"""
global _unknown_sells_restore_state, _unknown_sells_restore_failures
if _unknown_sells_restore_state != "pending":
return False
from app.core.redis import get_redis
client = get_redis()
if client is None:
_unknown_sells_restore_state = "ok"
logger.warning(
"[SwingExit] Redis 미연결 — 결과 불명 매도 대기 복원 불가(이전 프로세스의 불명 매도가 있었다면 "
"체결분 장부 반영 누락 가능, 수동 확인 필요)"
)
return False
try:
raw_map = await client.hgetall(_unknown_sells_redis_key_v2())
except Exception as e:
_unknown_sells_restore_failures += 1
if _unknown_sells_restore_failures > _UNKNOWN_SELL_RESTORE_HOLD_CYCLES:
_unknown_sells_restore_state = "gave_up"
logger.error(
f"[SwingExit] 🚨 결과 불명 매도 대기 복원 {_unknown_sells_restore_failures}회 실패 — "
f"복원 포기, 분할 익절 보류 해제 ({type(e).__name__}: {e}). 재기동 전 불명 매도가 있었다면 "
"remaining_pct·ActiveTrade 수동 확인 필요"
)
return False
logger.warning(
f"[SwingExit] 결과 불명 매도 대기 복원 실패({_unknown_sells_restore_failures}회) — "
f"이 주기 분할 익절 매도 보류, 다음 주기 재시도 ({type(e).__name__}: {e})"
)
return True
restored = 0
for code, raw in (raw_map or {}).items():
code = code.decode() if isinstance(code, bytes) else str(code)
if code in _pending_unknown_sells:
continue
entry = _parse_restored_unknown_sell_v2(code, raw)
if entry is None:
continue
_pending_unknown_sells[code] = entry
restored += 1
_unknown_sells_restore_state = "ok"
if restored:
logger.warning(
f"[SwingExit] 재기동 전 결과 불명 매도 대기 {restored}건 복원 — KIS 잔고 대조로 장부 반영 예정"
)
return False
def _unknown_sell_expired_v2(pend: dict, now_kst_dt=None) -> bool:
"""[하드닝 R9 2026-10-12, §8.5-38 H-C] 등록일(KST) 장 마감(15:30) 이후면 True. 등록 시각 불명이면 False."""
from datetime import datetime, time as dtime
from app.core.timeutil import KST, now_kst
since = pend.get("since")
try:
since_f = float(since)
except (TypeError, ValueError):
return False
now_dt = now_kst_dt or now_kst() # KST naive (timeutil 규약)
reg = datetime.fromtimestamp(since_f, tz=KST).replace(tzinfo=None)
close_dt = datetime.combine(reg.date(), dtime(15, 30))
if reg >= close_dt:
# 장 마감 이후 등록(정상 운영에선 없음)은 다음 날 장 마감까지 유지
from datetime import timedelta
close_dt += timedelta(days=1)
return now_dt > close_dt
async def _try_reconcile_before_expire_v2(code: str) -> bool:
"""TTL 폐기 직전 대조 1회. 대기가 해제됐으면(체결 반영/미체결 확인) True. 조회 실패·대기 유지면 False."""
try:
snap = await _presell_kis_holding_v2(code)
if snap is not None:
await _reconcile_unknown_sell_v2(code, *snap)
except Exception as e:
logger.warning(f"[SwingExit] {code}: TTL 폐기 전 결과 불명 매도 대조 실패 — {type(e).__name__}: {e}")
return False
return code not in _pending_unknown_sells
async def _expire_unknown_sells_v2(now_kst_dt=None) -> list:
"""[하드닝 R9 2026-10-12, §8.5-38 H-C] 결과 불명 매도 대기 폐기 — 반환 = 폐기한 종목.
(1) 등록일 장 마감(15:30 KST) 이후: 당일 미체결 주문은 소멸하므로 대조 없이 폐기.
(2) 대기 등록 이후 같은 종목의 신규 매수 접수가 관측됨(trade_executor 사이클 매수 누적):
대기의 pre_hldg 가 새 포지션과 섞여 가짜 체결이 기록될 수 있으므로 폐기.
어느 쪽이든 체결분 장부 반영이 누락됐을 수 있어 warning + 텔레그램(수동 확인)을 남긴다.
판정 자체는 try 밖 — 매수 누적 조회 실패만 warning 후 (1) 만 적용한다.
"""
if not _pending_unknown_sells:
return []
recent_buys: dict = {}
try:
from app.services.trade_executor import _recent_cycle_buys_v2
recent_buys = _recent_cycle_buys_v2() or {}
except Exception as e:
logger.warning(f"[SwingExit] 사이클 매수 누적 조회 실패 — 재매수 폐기 판정 생략: {type(e).__name__}: {e}")
dropped = []
for code, pend in list(_pending_unknown_sells.items()):
why = None
if _unknown_sell_expired_v2(pend, now_kst_dt):
# 폐기 전 KIS 잔고 대조 1회 — 장 후반 등록 후 사이클이 없었던 항목이 대조 없이 버려지지 않게.
# 대조로 판정(체결 반영 또는 미체결 확인 해제)되면 그 결과로 종결, 못 하면 폐기+경보.
if await _try_reconcile_before_expire_v2(code):
continue
why = "등록일 장 마감 경과"
else:
buy = recent_buys.get(code)
if isinstance(buy, dict):
try:
buy_ts = float(buy.get("ts", 0))
reg_mono = pend.get("since_mono")
# 복원된 대기(since_mono 없음)는 이 프로세스의 모든 매수보다 먼저 등록된 것
if reg_mono is None or buy_ts > float(reg_mono):
why = "등록 이후 같은 종목 신규 매수 접수"
except (TypeError, ValueError) as e:
logger.warning(f"[SwingExit] {code}: 재매수 폐기 판정 시각 형식 이상 — 대기 유지 ({e})")
if why is None:
continue
_pending_unknown_sells.pop(code, None)
await _forget_unknown_sell_v2(code)
dropped.append(code)
msg = (
f"[SwingExit] {code}: 결과 불명 매도 대기({pend.get('qty')}주) 대조 없이 폐기 — {why}. "
"체결분 장부(TradeHistory·ActiveTrade·loss_tracker) 반영이 누락됐을 수 있음, 수동 확인 필요"
)
logger.warning(msg)
_notify_swing_exit_alert_v2(f"unknown_sell_expired:{code}", "SWING_EXIT", msg)
return dropped
async def _reconcile_orphan_unknown_sells_v2(holding_codes: set) -> None:
"""보유 목록에 없는 종목의 결과 불명 대기를 KIS 잔고로 대조.
재기동 후 Portfolio sync 가 전량 체결된 행을 지우면 그 종목은 청산 루프에 들어오지 않아,
대조가 영영 안 되고 ActiveTrade 가 유령으로 남는다. 대조 불가(잔고 조회 실패)면 대기를 유지.
"""
for code in [c for c in list(_pending_unknown_sells) if c not in holding_codes]:
try:
snap = await _presell_kis_holding_v2(code)
if snap is None:
continue
await _reconcile_unknown_sell_v2(code, *snap)
except Exception as e:
logger.warning(
f"[SwingExit] {code}: 보유 목록 밖 결과 불명 매도 대조 실패 — {type(e).__name__}: {e} (대기 유지)"
)
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
def _holding_from_rows_v2(rows: list, stock_code: str) -> "tuple[bool, int, int] | None":
"""잔고 행 목록에서 (발견 여부, 보유, 주문가능). 형식 이상이면 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
return found, hq, sq
async def _fetch_kis_holding_v3(stock_code: str) -> "tuple[int, int] | None":
"""[하드닝 R9 2026-10-12, §8.5-37] v2 + 연속조회(get_balance_all_v2).
- 예외·오류 응답(rt_cd≠0)·형식 이상: None(보류) — v2 와 동일.
- 완전 응답(`_pages` 있음, `_partial` 없음): 미발견 = 미보유 (0, 0) 확정. 행 수가 페이지 한도
이상이어도 보류하지 않는다(20종목 이상 보유 시 2페이지 종목의 손절이 풀린다).
- 불완전 응답(`_partial` True): 미발견이면 다음 페이지에 있을 수 있음 → None(보류, error).
- `_pages` 없는 응답(구 형태): v2 의 "첫 페이지 가득 + 미발견 = 보류" 휴리스틱 유지.
"""
try:
bal = await korea_investment_api.get_balance_all_v2()
except Exception as e:
logger.error(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
got = _holding_from_rows_v2(rows, stock_code)
if got is None:
return None
found, hq, sq = got
if not found:
if "_pages" in bal:
if bal.get("_partial"):
logger.error(
f"[SwingExit] {stock_code} KIS 잔고 연속조회 불완전({bal.get('_pages')}페이지, {len(rows)}행)"
f"에 없음 — 다음 페이지 가능, 매도 보류"
)
return None
elif len(rows) >= int(settings.SWING_BALANCE_FULL_PAGE_ROWS):
logger.error(
f"[SwingExit] {stock_code} KIS 잔고 첫 페이지({len(rows)}행)에 없음 — 페이지 초과 가능, 매도 보류"
)
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_v3(stock_code) # [R9] 연속조회(§8.5-37)
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, spawn_tracked_task_v2
# [하드닝 R2] 강참조·종료 drain 대상(trade_executor 레지스트리) — bare create_task 금지
asyncio.get_running_loop() # 루프 없으면 코루틴을 만들기 전에 RuntimeError
spawn_tracked_task_v2(_delayed_sync_portfolio(), name="swing_unknown_sell_sync")
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)
await _forget_unknown_sell_v2(stock_code)
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 매도({pend['qty']}주) 미체결 확인 "
f"(KIS 보유 {kis_hldg}주 불변) — 대기 해제, 다음 주기 재판정"
)
return False
_pending_unknown_sells.pop(stock_code, None)
await _forget_unknown_sell_v2(stock_code)
price = float(pend["price"])
avg = float(pend["avg_price"])
swing_qty = int(pend["swing_qty"])
is_full = filled >= swing_qty
# [2026-10-09 R1] 순손익(비용 차감) — 판정·기록 모두 이 값(§5.1). 결과 불명 대기는 국내만 등록.
_pnl = _swing_net_pnl_v2(avg, price, filled, pend.get("market"), domestic=True)
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"]),
# [2026-10-08] 주문번호가 없어 체결가를 조회할 수 없다 — 신호가를 체결가로 쓰지 않는다
# (total_amount·손익은 신호가 기준 추정치로 남는다).
"executed_price": None,
"executed_quantity": filled,
"total_amount": price * filled,
"status": "COMPLETED",
"order_id": "",
"exit_reason": f"{pend['reason']} (결과 불명 → KIS 잔고로 체결 확인)",
"commission": _pnl["total_cost"],
"net_profit_loss": _pnl["net_profit_loss"],
# 수익률은 기존 executor 규약대로 비용 전(gross) — 체결 확인(net_rate=False)과 동일
"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=_position_is_loss_v2(pend.get("prior_realized_net"), _pnl["net_profit_loss"]),
)
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}")
# ─── 청산 RSI 워밍업용 장기 일봉 (하드닝 R4 2026-10-09) ─────────────────────────
# KIS get_daily_chart(FHKST01010400)는 limit 과 무관하게 30행만 돌려준다(entry_service 주석,
# 로컬 로그 N=30). Wilder RSI(14)를 30봉으로 계산하면 시드 영향이 ≈(13/14)^15≈33% 남아
# 1차 익절(RSI≥55)·전량(RSI≥70) 판정이 전체 이력 RSI 와 어긋났다(운영 150종목×120봉 실측:
# |Δ| 평균 2.58·p95 6.96, ≥55 불일치 6.9%, ≥70 불일치 1.3%). 백테스트는 전체 이력을 쓴다.
#
# 설계: 장기 이력(네이버 → KIS 기간 조회)은 종목당 **하루 1회** 백그라운드로 받아 캐시하고,
# 매 사이클의 KIS 30봉(오늘 봉 포함, 실시세에 가까움)과 **날짜로 병합**해 RSI 만 계산한다.
# - KIS 30봉이 겹치는 날짜를 덮어쓴다 → 최근 30봉 값·오늘 봉은 기존과 동일 출처.
# - 겹치는 확정 봉의 종가가 어긋나면(수정주가 차이·소스 오류) 병합을 버리고 기존 30봉으로 폴백.
# - ATR(최근 14 TR 단순평균)·BB(최근 20봉)·lows[-5:] 는 30봉으로 이미 정확하므로 그대로 둔다.
# 청산 루프 지연 = 손절 지연이므로 조회 대기는 EXIT_HISTORY_WAIT_SEC 로 상한을 둔다. 대기 안에
# 끝나지 않으면 이번 사이클은 기존 30봉 RSI 로 판정하고(손절 판정은 영향 없음) 다음 사이클에 쓴다.
EXIT_RSI_HISTORY_MIN_BARS = 100 # 병합 후 이 봉수 미만이면 warning(그래도 30봉보다 길면 사용)
_EXIT_HISTORY_NAVER_DAYS = 150 # get_naver_daily_chart: 캘린더 (N+30)일 ≈ 120 거래일
_EXIT_HISTORY_KIS_CALENDAR_DAYS = 180
EXIT_HISTORY_WAIT_SEC = 2.0 # 사이클 내 대기 상한(초) — 손절 지연 방지
_EXIT_HISTORY_FETCH_TIMEOUT_SEC = 15.0 # 백그라운드 소스별 조회 상한
_EXIT_HISTORY_RETRY_SEC = 1800.0 # 실패 후 재조회 간격(초)
_EXIT_HISTORY_CLOSE_TOL = 0.005 # 겹치는 확정 봉 종가 허용 오차(0.5%)
# stock_code -> {"date": "YYYYMMDD"(KST), "bars": list|None, "src": str, "failed_at": monotonic|None}
_exit_history_cache: dict[str, dict] = {}
_exit_history_tasks: dict[str, "asyncio.Task"] = {}
# 사이클별 RSI 출처 집계(A-B 관측용): "merged" / "kis30"
_exit_rsi_source_counts: dict[str, int] = {"merged": 0, "kis30": 0}
def _bar_close_v2(bar) -> "float | None":
try:
c = float(bar.get("stck_clpr"))
except (TypeError, ValueError, AttributeError):
return None
if c != c or c <= 0: # NaN·0·음수
return None
return c
async def _fetch_exit_history_bars_v2(stock_code: str) -> tuple[list, str]:
"""장기 일봉(오래된 → 최신). 네이버 → KIS 기간 조회. 둘 다 실패면 ([], "").
예외를 던지지 않는다(호출부가 백그라운드 태스크).
"""
from datetime import timedelta
from app.core.timeutil import today_kst
best: list = []
best_src = ""
try:
from app.services.naver_finance import get_naver_daily_chart
nv = await asyncio.wait_for(
get_naver_daily_chart(stock_code, days=_EXIT_HISTORY_NAVER_DAYS),
timeout=_EXIT_HISTORY_FETCH_TIMEOUT_SEC,
)
nv = [b for b in (nv or []) if isinstance(b, dict) and b.get("stck_bsop_date")]
nv = sorted({str(b["stck_bsop_date"]): b for b in nv}.values(),
key=lambda b: str(b["stck_bsop_date"]))
if len(nv) >= EXIT_RSI_HISTORY_MIN_BARS:
return nv, "naver"
best, best_src = nv, "naver"
except Exception as e:
warn_throttled(
logger, "exit_service:history_naver",
f"[SwingExit] {stock_code} 청산 RSI용 네이버 일봉 조회 실패(KIS 기간 조회로): "
f"{type(e).__name__}: {e}",
)
try:
src_api = korea_investment_api
_delegate = getattr(korea_investment_api, "_quote_delegate", None)
if callable(_delegate):
_d = _delegate()
if _d is not None:
src_api = _d
end = today_kst()
start = end - timedelta(days=_EXIT_HISTORY_KIS_CALENDAR_DAYS)
rows = await asyncio.wait_for(
src_api.get_daily_ohlcv_range(
stock_code, start.strftime("%Y%m%d"), end.strftime("%Y%m%d"),
),
timeout=_EXIT_HISTORY_FETCH_TIMEOUT_SEC,
)
rows = [r for r in (rows or []) if isinstance(r, dict) and r.get("stck_bsop_date")]
kis = sorted({str(r["stck_bsop_date"]): r for r in rows}.values(),
key=lambda r: str(r["stck_bsop_date"]))
if len(kis) > len(best):
best, best_src = kis, "kis_range"
except Exception as e:
warn_throttled(
logger, "exit_service:history_kis_range",
f"[SwingExit] {stock_code} 청산 RSI용 KIS 기간 일봉 조회 실패: {type(e).__name__}: {e}",
)
return best, best_src
async def _load_exit_history_v2(stock_code: str, day: str) -> None:
"""백그라운드: 장기 일봉을 받아 캐시에 넣는다. 예외를 밖으로 내지 않는다."""
try:
bars, src = await _fetch_exit_history_bars_v2(stock_code)
except Exception as e: # 방어 — 헬퍼는 예외를 던지지 않도록 작성됨
logger.warning(f"[SwingExit] {stock_code} 청산 RSI 이력 로드 예외: {type(e).__name__}: {e}")
bars, src = [], ""
if bars:
_exit_history_cache[stock_code] = {"date": day, "bars": bars, "src": src, "failed_at": None}
else:
_exit_history_cache[stock_code] = {
"date": day, "bars": None, "src": "", "failed_at": time.monotonic(),
}
async def _get_exit_history_v2(stock_code: str) -> "list | None":
"""오늘(KST) 캐시된 장기 일봉. 없으면 백그라운드 로드를 시작하고 최대 EXIT_HISTORY_WAIT_SEC 대기."""
from app.core.timeutil import today_kst
day = today_kst().strftime("%Y%m%d")
ent = _exit_history_cache.get(stock_code)
if ent and ent.get("date") == day:
if ent.get("bars"):
return ent["bars"]
fa = ent.get("failed_at")
if fa is not None and time.monotonic() - fa < _EXIT_HISTORY_RETRY_SEC:
return None
# 날짜가 바뀐 캐시는 정리(무한 증가 방지)
for k in [k for k, v in _exit_history_cache.items() if v.get("date") != day]:
_exit_history_cache.pop(k, None)
task = _exit_history_tasks.get(stock_code)
if task is None or task.done():
from app.core.background_tasks import spawn_tracked_v2
task = spawn_tracked_v2(_load_exit_history_v2(stock_code, day))
_exit_history_tasks[stock_code] = task
task.add_done_callback(
lambda t, c=stock_code: _exit_history_tasks.pop(c, None)
if _exit_history_tasks.get(c) is t else None
)
try:
await asyncio.wait_for(asyncio.shield(task), timeout=EXIT_HISTORY_WAIT_SEC)
except asyncio.TimeoutError:
return None # 이번 사이클은 30봉으로 — 다음 사이클에 사용
except Exception as e:
logger.warning(f"[SwingExit] {stock_code} 청산 RSI 이력 대기 예외: {type(e).__name__}: {e}")
return None
ent = _exit_history_cache.get(stock_code)
if ent and ent.get("date") == day and ent.get("bars"):
return ent["bars"]
return None
def _merge_exit_bars_v2(history: "list | None", recent: list) -> "list | None":
"""장기 이력 + 최근 KIS 봉(둘 다 오래된 → 최신)을 날짜로 병합. 검증 실패 시 None.
- 최근 봉이 겹치는 날짜를 덮어쓴다(최근 30봉·오늘 봉은 기존 출처 그대로).
- 정합 검증: 최근 봉의 **마지막(진행 중일 수 있는) 봉을 제외한** 겹치는 날짜가 1개 이상이고,
그 종가가 모두 _EXIT_HISTORY_CLOSE_TOL 이내여야 한다(수정주가 차이·소스 불일치 차단).
- 이력에 최근 봉의 첫 날짜보다 이른 봉이 없으면(이득 없음) None.
"""
if not history or not recent:
return None
rec = {}
for b in recent:
d = str(b.get("stck_bsop_date") or "")
if len(d) != 8:
return None # 날짜 없는 최근 봉 — 병합 불가
rec[d] = b
rec_dates = sorted(rec)
last_recent = rec_dates[-1]
hist = {}
for b in history:
d = str(b.get("stck_bsop_date") or "")
if len(d) == 8 and d <= last_recent: # 최근 봉보다 미래 날짜는 무시
hist[d] = b
overlap = [d for d in rec_dates[:-1] if d in hist]
if not overlap:
return None
for d in overlap:
ch, cr = _bar_close_v2(hist[d]), _bar_close_v2(rec[d])
if ch is None or cr is None:
return None
if abs(ch - cr) / cr > _EXIT_HISTORY_CLOSE_TOL:
return None
older = [hist[d] for d in sorted(hist) if d < rec_dates[0]]
if not older:
return None
merged = older + [rec[d] for d in rec_dates]
if any(_bar_close_v2(b) is None for b in older):
return None
return merged
async def _exit_rsi_v2(stock_code: str, recent_bars: list, closes_recent: list):
"""청산 판정용 RSI. 장기 이력 병합에 성공하면 그 RSI, 아니면 기존 30봉 RSI.
예외를 던지지 않는다 — 실패 시 기존 값(closes_recent 기준)으로 폴백.
"""
rsi_recent = calculate_rsi(closes_recent)
# 날짜 없는 최근 봉은 병합할 수 없다 — 이력 조회 자체를 하지 않는다(불필요한 외부 호출 방지).
if not recent_bars or any(
not isinstance(b, dict) or len(str(b.get("stck_bsop_date") or "")) != 8
for b in recent_bars
):
_exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1
return rsi_recent
try:
history = await _get_exit_history_v2(stock_code)
merged = _merge_exit_bars_v2(history, recent_bars)
if merged is None or len(merged) <= len(closes_recent):
if history:
warn_throttled(
logger, "exit_service:history_merge",
f"[SwingExit] {stock_code} 청산 RSI 장기 이력 병합 불가(겹침/종가 불일치) — "
f"KIS {len(closes_recent)}봉 RSI 사용",
)
_exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1
return rsi_recent
if len(merged) < EXIT_RSI_HISTORY_MIN_BARS:
warn_throttled(
logger, "exit_service:history_short",
f"[SwingExit] {stock_code} 청산 RSI 이력 {len(merged)}봉 "
f"(<{EXIT_RSI_HISTORY_MIN_BARS}) — 워밍업 부족 가능",
)
# 최근 봉은 기존 경로와 같은 변환(float(stck_clpr, 0))을 쓴다 — 이력 봉은 위에서 검증됨.
rsi_full = calculate_rsi([float(b.get("stck_clpr", 0)) for b in merged])
if rsi_full is None:
_exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1
return rsi_recent
_exit_rsi_source_counts["merged"] = _exit_rsi_source_counts.get("merged", 0) + 1
return rsi_full
except Exception as e:
warn_throttled(
logger, "exit_service:history_rsi",
f"[SwingExit] {stock_code} 청산 RSI 장기 이력 계산 실패 — 30봉 RSI 사용: "
f"{type(e).__name__}: {e}",
)
_exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1
return rsi_recent
# [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 _nonstale_fallback_stop_pct_v2(stock_code: str, atr, price: float, entry_at) -> float:
"""실시세는 받았지만 지표 판정이 불가한 경우(일봉 조회 실패·20봉 미만·ATR 산출 실패)의 손절률.
[하드닝 R1 2026-10-09] 이전엔 ATR 이 None 이면 `_fallback_stop_loss_pct_v2(None)` →
일반 손절률(-7%)로 판정돼, 고변동 종목(지표 경로 손절선 -7.5%~-10%)이 차트 API 1회 실패로
오손절됐다(대체가 경로는 H-1 6회차에서 이미 캐시 ATR/느슨한 쪽으로 고쳐져 있어 손절 기준이
"API 성공 여부"에 따라 달라졌다). 이제 규칙을 대체가 경로와 통일한다:
- 이번 주기 ATR 이 유효(>0)하면 그 ATR 로 판정(기존 동작)
- 없으면 `_stale_fallback_stop_pct_v2` — 신선·진입 이후의 캐시 ATR, 그것도 없으면
일반·고변동 중 느슨한 손절률
반환값은 항상 음수(부호 가드는 각 하위 함수가 try 밖에서 건다).
"""
try:
_a = float(atr) if atr is not None else 0.0
except (TypeError, ValueError):
_a = 0.0
if _a > 0:
return _fallback_stop_loss_pct_v2(_a, price, stock_code)
return _stale_fallback_stop_pct_v2(stock_code, price, entry_at)
async def _prior_position_realized_net_v2(db: AsyncSession, stock_code: str, active_trade, market):
"""이 포지션에서 **이번 레그 이전에** 실현된 순손익 합계(원), 판단 불가면 None.
[하드닝 R1 2026-10-09] 전량 청산 시 loss_tracker 승패를 마지막 레그의 gross 로 판정해,
1차 익절(+) 후 잔량 소폭 손실 청산이 '패'로 집계됐다(449450: +3,900·+4,125 → −410).
포지션 단위 누적 순손익 = (진입 이후 비-DT COMPLETED SELL 의 순손익 합) + 이번 레그 순손익.
- 진입 시각 = `_resolve_time_stop_entry_v3` 기준(동기화·시딩 행은 원 BUY 일자)
- commission 이 NULL 인 과거 행(비용 전 gross 기록)은 기록값에서 진입가를 역산해
왕복 비용을 빼 순손익으로 환산한다
SELECT 전용(commit 없음) — DB-READ-EXCEPTION: CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session.
"""
if active_trade is None:
return None
try:
basis = await _resolve_time_stop_entry_v3(db, stock_code, active_trade)
lower = getattr(basis, "created_at", None)
if lower is None:
return None
from app.models.models import TradeHistory
from app.services.db_worker import not_dt_strategy_clause
res = await db.execute(
select(
TradeHistory.net_profit_loss, TradeHistory.commission,
TradeHistory.total_amount, TradeHistory.executed_quantity,
TradeHistory.order_quantity,
).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "COMPLETED",
TradeHistory.created_at >= lower,
not_dt_strategy_clause(TradeHistory.strategy_type),
)
)
rows = res.all()
except Exception as e:
logger.warning(
f"[SwingExit] {stock_code} 포지션 누적 실현손익 조회 실패 — 이번 레그 순손익만으로 "
f"승패 판정: {type(e).__name__}: {e}"
)
return None
total = 0.0
try:
for r in rows:
npl, comm, amt, eq, oq = r[0], r[1], r[2], r[3], r[4]
if npl is None:
continue
npl = float(npl)
if comm is not None:
total += npl
continue
q = int(eq if eq is not None else (oq or 0))
if q <= 0 or not amt or float(amt) <= 0:
total += npl
continue
p = float(amt) / q
entry = p - npl / q
if entry <= 0:
total += npl
continue
total += float(_swing_net_pnl_v2(entry, p, q, market)["net_profit_loss"])
except Exception as e:
logger.warning(
f"[SwingExit] {stock_code} 포지션 누적 실현손익 환산 실패 — 이번 레그 순손익만으로 "
f"승패 판정: {type(e).__name__}: {e}"
)
return None
return total
def _position_is_loss_v2(prior_realized_net, leg_net) -> bool:
"""전량 청산 승패 — 포지션 누적 순손익(이전 레그 합 + 이번 레그) < 0. 이전 합을 모르면 레그만."""
total = float(leg_net)
if prior_realized_net is not None:
total += float(prior_realized_net)
return total < 0
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 _swing_tax_market_v2(market) -> str:
"""스윙 매도 거래세 시장 구분 — 체결 확인(_schedule_swing_sell_fill_check_v2)과 같은 매핑."""
return "KOSDAQ" if str(market or "").upper() in ("KOSDAQ", "KQ") else "KOSPI"
def _swing_net_pnl_v2(
entry_price: float, exit_price: float, quantity: int, market, domestic: bool = True,
) -> dict:
"""[2026-10-09 R1] 스윙 실현손익(비용 차감) — CLAUDE.md §5.1 "손익 판정은 비용 차감 순손익".
비용 정의는 trade_cost_core(= trade_helpers.calculate_trade_costs, DT dt_net_pnl 과 동일):
매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(국내만, KOSPI/KOSDAQ 세율).
이전엔 스윙 전량 청산의 loss_tracker 판정과 TradeHistory.net_profit_loss 가 총손익이라
+0.1% 청산이 스윙에선 승, DT 에선 패로 집계됐다(3연패 밴이 경로별로 다르게 발동).
반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate
"""
from app.services.trade_cost_core import net_realized_pnl
if not domestic:
tax_rate = 0.0
elif _swing_tax_market_v2(market) == "KOSDAQ":
tax_rate = float(settings.KR_SELL_TAX_RATE_KOSDAQ)
else:
tax_rate = float(settings.KR_SELL_TAX_RATE_KOSPI)
return net_realized_pnl(
float(entry_price), float(exit_price), int(quantity),
float(settings.KR_COMMISSION_RATE), tax_rate,
)
_SEEDED_ENTRY_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션")
_SEEDED_STRATEGY_NAMES = ("synced",)
def _is_seeded_active_trade_v2(active_trade) -> bool:
"""created_at 이 원 진입일이 아닐 수 있는 행(동기화·시딩·부분매도 자동 생성)."""
_name = getattr(active_trade, "strategy_name", None) or ""
_reason = getattr(active_trade, "entry_reason", None) or ""
return _name in _SEEDED_STRATEGY_NAMES or _reason.startswith(_SEEDED_ENTRY_REASON_PREFIXES)
async def _resolve_time_stop_entry_v3(db: AsyncSession, stock_code: str, active_trade):
"""시간손절 기준 진입일 보정 v3 — v2 의 인식 범위·하한 결함 보완.
[하드닝 R1 2026-10-09]
(1) 인식: v2 는 entry_reason '동기화 유입…' 만 보정했다. sync_portfolio 시딩 행은
strategy_name='synced' / entry_reason '외부 유입 포지션 — 동기화 시 자동 등록…' 이라
항상 created_at(재시딩 시각)이 진입일로 쓰여 20거래일 시계가 재시딩마다 0 이 됐다
(181710: BUY 9/23, synced created_at 10/6).
(2) 하한: v2 의 "closed ActiveTrade 최신 updated_at" 은 sync 레이스로 닫힌 swing 행
(실제 매도 없음)의 updated_at 이 되어 원 BUY 를 범위에서 뺐다. v3 는 TradeHistory
(비-DT·COMPLETED·created_at ≤ 행 created_at)를 시간순으로 누적해 수량이 0 이하로
떨어진 **마지막 SELL** 을 마지막 전량 청산으로 본다.
범위 안 **가장 최근** BUY 일자를 쓴다(실제 최초 진입보다 이르지 않다 → 조기 청산 없음).
한계: TradeHistory 에 없는 외부 매도가 있었다면 청산점이 잡히지 않아 그 이전 BUY 가 쓰일 수
있다(그래도 시간손절은 '수익 < 최소수익' 일 때만 발동).
BUY 없음·조회 실패·비교 불가면 기존 created_at. SELECT 전용(commit 없음) —
DB-READ-EXCEPTION: CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session.
"""
if active_trade is None:
return None
_created = getattr(active_trade, "created_at", None)
if _created is None or not _is_seeded_active_trade_v2(active_trade):
return active_trade
_orig = None
try:
from app.models.models import TradeHistory
from app.services.db_worker import not_dt_strategy_clause
_res = await db.execute(
select(
TradeHistory.order_type, TradeHistory.executed_quantity,
TradeHistory.order_quantity, TradeHistory.created_at,
).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type.in_(("BUY", "SELL")),
TradeHistory.status == "COMPLETED",
TradeHistory.created_at <= _created,
not_dt_strategy_clause(TradeHistory.strategy_type),
).order_by(TradeHistory.created_at.asc(), TradeHistory.id.asc())
)
running = 0
for _ot, _eq, _oq, _ts in _res.all():
_q = int(_eq if _eq is not None else (_oq or 0))
if _ot == "BUY":
running += _q
_orig = _ts
else:
running -= _q
if running <= 0:
running = 0
_orig = None # 전량 청산 — 그 이전 BUY 는 이 포지션의 진입이 아니다
except Exception as e:
warn_throttled(
logger, "exit_service:time_stop_entry_v3",
f"[SwingExit] 시간손절 원 진입일 조회 실패 ({stock_code}) — created_at 사용: "
f"{type(e).__name__}: {e}",
)
_orig = None
if _orig is None or not hasattr(_orig, "date"):
return active_trade
try:
_is_earlier = _orig < _created
except TypeError as e:
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 _schedule_swing_sell_fill_check_v2(
order_id: str, stock_code: str, stock_name: str, sell_qty: int,
ref_price: float, entry_price: float, market: str | None,
) -> None:
"""[2026-10-08] 스윙 시장가 매도의 체결 확인을 백그라운드로 예약 (청산 루프 비차단).
trade_executor._verify_and_sync_execution_v3 를 재사용한다 — 체결가·체결수량·total_amount·
순손익(수수료·세금 차감)을 실제 체결 기준으로 갱신한다. 미체결 자동 취소는 하지 않는다
(시장가 손절을 취소하면 청산이 사라진다).
"""
from app.services.trade_executor import _verify_and_sync_execution_v3, spawn_tracked_task_v2
_mkt = _swing_tax_market_v2(market)
try:
asyncio.get_running_loop() # 루프 없으면 코루틴을 만들기 전에 RuntimeError
# [하드닝 R2 2026-10-09] bare create_task → spawn_tracked_task_v2: 강참조 보관(GC 로 중도
# 소멸 방지) + 종료 drain(drain_tracked_tasks_v2) 대상. 이전엔 반환값을 버려 배포 직전
# 매도의 체결가·순손익 보정이 유실될 수 있었다.
spawn_tracked_task_v2(
_verify_and_sync_execution_v3(
order_id, stock_code, stock_name, int(sell_qty), float(ref_price), "SELL",
True, "KRX", 0, delay=3,
sell_pnl_basis={
"entry_price": float(entry_price),
"market": _mkt,
"strategy_type": "swing",
"net_rate": False,
},
),
name=f"swing_sell_fill:{stock_code}",
)
except RuntimeError as e:
logger.warning(f"[SwingExit] {stock_code} 체결 확인 예약 실패: {e}")
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 = []
# [하드닝 R2 2026-10-09] 재기동 전 결과 불명 매도 대기 복원(최초 성공 1회). 일시 장애로 복원
# 실패면 이 주기 국내 분할 익절만 보류한다 — 가드라 try 밖(§4). 헬퍼는 예외를 내지 않는다.
_hold_partials_unrestored = await _restore_unknown_sells_v2()
# [하드닝 R4] 실행 결과 집계(바깥 finally 에서 기록 — 스케줄러가 실패로 집계할 근거)
_run_holding_codes: list = []
_run_errored: dict = {}
_run_fatal = None
try:
# strategy_type='swing' 포지션만 조회
_now_ts = time.time()
holdings = await get_swing_holdings(db)
_run_holding_codes = [str(getattr(h, "stock_code", "")) for h in (holdings or [])]
# [하드닝 R1-b] 휴면(판정 대상 밖 보유)·잔고 페이지 포화 감시 — 경보 전용, 실패해도 판정 계속
_census = await _portfolio_census_v2(db)
try:
_apply_census_v2(_census, len(_run_holding_codes))
except Exception as e:
logger.warning(f"[SwingExit] 보유 집계 경보 처리 실패(판정 계속): {type(e).__name__}: {e}")
# [하드닝 R9] 불명 대기 TTL(장 마감)·재매수 폐기 — 대조보다 먼저(§8.5-38 H-C)
await _expire_unknown_sells_v2()
# [하드닝 R2] 보유 목록 밖(전량 체결 후 sync 로 행 삭제 등)의 불명 대기 대조 — 유령 ActiveTrade 방지
if _pending_unknown_sells:
await _reconcile_orphan_unknown_sells_v2(
{str(getattr(h, "stock_code", "")) for h in (holdings or [])}
)
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}주 대상"
)
# [2026-10-08] Portfolio 평단은 스윙+DT 혼합일 수 있다(§3.5, KIS 이동평균법이라
# DT 청산 후에도 남는다). 손익·손절 기준은 아래에서 스윙 ActiveTrade 진입가로 정한다.
_portfolio_avg = 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).
# ── ActiveTrade 조회 (분할 청산 상태) ────────────────────
# [H-1 2026-10-02] 대체 가격의 "진입 이전 값 거부"에 진입 시각이 필요해 현재가
# 조회 앞으로 옮겼다(조회 자체는 동일).
active_trade = await get_swing_active_trade(db, stock_code)
avg_price, _entry_basis = resolve_swing_entry_price_v2(
getattr(active_trade, "entry_price", None) if active_trade is not None else None,
_portfolio_avg,
stock_code,
)
if avg_price <= 0:
continue
if abs(avg_price - _portfolio_avg) >= 0.01:
from app.core.log_throttle import should_log as _should_log_ep
_ep_ok, _ = _should_log_ep(f"exit_service:entry_basis:{stock_code}")
else:
_ep_ok = False
if _ep_ok:
logger.info(
f"[SwingExit] {stock_code}: 스윙 진입가 {avg_price:,.2f} (기준={_entry_basis}, "
f"Portfolio 평단 {_portfolio_avg:,.2f})"
)
_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)
# [하드닝 R4] RSI 만 장기 이력 병합으로 워밍업(실패 시 30봉 RSI 그대로).
# ATR(최근 14 TR)·BB(최근 20봉)는 30봉으로 이미 정확하다.
rsi = await _exit_rsi_v2(
stock_code, list(reversed(chart_data)), 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:
# [하드닝 R1] v3: 'synced'·'외부 유입' 시딩 행 인식 + TradeHistory 기반 전량청산 하한
_ts_basis = await _resolve_time_stop_entry_v3(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,
)
elif domestic:
# [하드닝 R1 2026-10-09] 실시세는 있으나 일봉/지표 조회 실패·20봉 미만 —
# 대체가 경로와 같은 규칙(이번 ATR → 신선·진입 이후 캐시 ATR → 느슨한 쪽).
# 이전엔 ATR=None 으로 일반 손절(-7%)이 고변동 종목에 적용됐다.
_fb_sl_pct = _nonstale_fallback_stop_pct_v2(
stock_code, _fallback_atr, 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 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다.
# 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다.
# [하드닝 R8 2026-10-10] 쿨다운은 분할(sell_pct<1.0) 신호에만 적용한다. 국내 전량형
# 신호(손절·시간손절·폴백 손절·전량 익절)는 통과 — 이전엔 신호 종류를 보지 않아
# KIS 상한에 걸려 일부만 팔린 손절의 잔량, 1차 익절 직후 급락의 손절이 최소
# EXIT_COOLDOWN_SEC 동안 보류됐다(§8.1-11). 과다매도는 아래 매도 직전 KIS 실보유
# 상한(C-1: min(주문가능, 보유) − DT)이 막는다. 해외는 그 상한이 없어 기존대로 쿨다운.
# 판정은 try 밖(§4).
_last_exit = _recent_exits.get(stock_code)
if (_last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC
and _exit_cooldown_applies_v2(exit_signal, domestic)):
logger.info(
f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 "
f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 분할 신호 스킵 ({exit_signal.reason})"
)
continue
if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC:
logger.warning(
f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전이지만 "
f"전량형 신호({exit_signal.reason}) — 쿨다운({EXIT_COOLDOWN_SEC}초) 우회, "
f"KIS 실보유 상한으로 수량 결정"
)
# [하드닝 R2 2026-10-09] 결과 불명 대기 복원 실패 주기: 국내 분할(부분) 익절 보류.
# 복원 못 한 불명 체결이 있으면 remaining_pct 가 낡아 같은 단계가 재발동할 수 있다.
# 손절·전량 청산은 진행(청산 우선, 수량 상한은 매도 직전 KIS 실보유).
if (_hold_partials_unrestored and domestic
and float(getattr(exit_signal, "sell_pct", 1.0) or 1.0) < 1.0):
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 매도 대기 복원 실패 — 분할 익절 "
f"({exit_signal.reason}) 이 주기 보류"
)
continue
# [하드닝 R9 2026-10-12, §8.5-38 H-B] 결과 불명 매도 대기(미결) 중: 분할 익절은 건너뛴다 —
# remaining_pct 가 아직 갱신되지 않아 같은 단계가 재발동한다. 손절·전량 청산은 계속 허용
# (§8.1-11). 수량 상한은 아래 KIS 상한 min(주문가능, 보유) − DT — 불명 주문이 접수됐다면
# 주문가능이 이미 줄어 있어 과다매도가 안 난다. 판정은 try 밖(§4).
if (domestic and stock_code in _pending_unknown_sells
and float(getattr(exit_signal, "sell_pct", 1.0) or 1.0) < 1.0):
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 매도 대기 중 — 분할 익절({exit_signal.reason}) 건너뜀, "
"손절·전량 신호만 허용"
)
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
# [하드닝 R3 2026-10-09] 스윙 보유 기준(_swing_basis)과 이번 주문 상한(_cap)을 분리한다.
# - 주문 상한은 기존대로 min(주문가능, 보유) − DT 를 유지한다. 주문가능에서 빠진 대기
# 매도분(보유 − 주문가능)이 DT 것인지 스윙 것(결과 불명 매도·지정가 익절 미체결)인지
# 여기서 구분할 수 없고, 스윙 것인데 보유 − DT 로 상한을 잡으면 두 주문이 모두 체결될 때
# DT 물량까지 팔린다(과다매도). 대기분이 DT 것이면 과소 매도에 그치고 잔량은 다음
# 주기에 다시 판정된다 — 과다매도를 막는 쪽을 택한다.
# - '전량 청산' 판정은 상한 적용 전 _swing_basis = min(장부 스윙, 보유 − DT) 대비.
# 상한 때문에 일부만 판 경우는 분할 매도처럼 잔량·remaining_pct 만 갱신하고
# ActiveTrade close·loss_tracker·closed_today 는 하지 않는다(스윙 잔량 0 일 때만).
# 장부가 부풀어 있으면(sync 전) 보유 − DT 가 기준이 되어 KIS 스윙 잔량 0 이면 전량 청산.
_swing_basis = quantity
_stage_sell_pct = float(sell_pct)
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
_dt_held = max(0, int(_dt_qty or 0))
_kis_swing_hldg = max(0, _kis_hldg - _dt_held)
_cap = max(0, min(_kis_sellable, _kis_hldg) - _dt_held)
if _cap <= 0:
logger.warning(
f"[SwingExit] {stock_code}: KIS 기준 스윙 매도 가능 0 (보유 {_kis_hldg}, "
f"주문가능 {_kis_sellable}, DT {_dt_qty}) — 주문 안 함"
)
continue
if _kis_swing_hldg < quantity:
logger.warning(
f"[SwingExit] {stock_code}: 장부 스윙 {quantity}주 > KIS 보유 기준 "
f"{_kis_swing_hldg}주 (보유 {_kis_hldg} − DT {_dt_held}) — KIS 기준으로 상한"
)
_swing_basis = _kis_swing_hldg
total_qty = _kis_hldg
_intended_sell_qty = sell_qty
if sell_qty > _cap:
sell_qty = _cap
if sell_qty < min(_intended_sell_qty, _swing_basis):
logger.warning(
f"[SwingExit] {stock_code}: KIS 상한 {_cap}주(주문가능 {_kis_sellable}, "
f"보유 {_kis_hldg}, DT {_dt_held})로 {_intended_sell_qty}→{sell_qty}주만 매도 "
f"(스윙 보유 {_swing_basis}주, 미체결 주문 잔존 추정) — 분할 처리, 잔량은 다음 주기 재판정"
)
if _intended_sell_qty >= quantity:
# 전량형 신호(손절·전량 익절·소량 전량)가 상한에 걸림 → 분할 단계 차감 없이
# 실제 잔량 비율만 기록해 다음 주기 같은 신호가 잔량을 다시 판정하게 한다.
_stage_sell_pct = 0.0
_pre_hldg = _kis_hldg
_is_full_close = sell_qty >= _swing_basis
_swing_left = max(0, _swing_basis - sell_qty)
# [하드닝 R1-b] 기록 사유 = 실제 매도 수량 기준(소량 전량·KIS 상한 부분매도 명시)
_exit_reason = _effective_exit_reason_v2(
exit_signal.reason, sell_pct, sell_qty, _swing_basis, original_qty,
)
# [M-4 2026-10-02] 주문 전송 ~ TradeHistory·Portfolio·ActiveTrade·loss_tracker 반영
# 전체를 asyncio.shield 로 보호한다(DT _execute_exit_impl 과 같은 패턴). 스케줄러
# 종료·취소가 safe_place_order 대기 중에 들어오면 "브로커는 체결, 장부는 미반영" →
# 다음 기동 후 같은 물량 재매도가 날 수 있었다. 취소는 이 루프에만 전파되고
# shield 된 작업은 끝까지 진행된다.
# [하드닝 R1 2026-10-09] 전량 청산의 loss_tracker 승패는 포지션 단위 누적 순손익
# (이전 분할 레그 순손익 합 + 이번 레그 순손익)으로 판정한다. 이전 레그 합은 주문 전
# (shield 밖)에 SELECT 로 미리 구한다 — 실패하면 None → 이번 레그 순손익만으로 판정.
_prior_realized_net = None
if _is_full_close:
_prior_realized_net = await _prior_position_realized_net_v2(
db, stock_code, active_trade, market,
)
async def _sell_and_record_v2():
# ── KIS API 매도 주문 ─────────────────────────────────────
logger.info(
f"[SwingExit] 청산 신호 → {stock_code} {stock_name} "
f"수량={sell_qty} 이유={_exit_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":
# [하드닝 R9] 이미 대기 중(분할 불명 → 손절 불명)이면 덮어써서 앞선 불명 수량을
# 잃지 않도록 합산한다. 기준 잔고는 앞선 대기의 pre_hldg(더 이른 스냅샷)를 유지.
_prev_pend = _pending_unknown_sells.get(stock_code)
_new_qty = int(sell_qty)
_new_pre = int(_pre_hldg if _pre_hldg is not None else total_qty)
if _prev_pend:
logger.warning(
f"[SwingExit] {stock_code}: 기존 결과 불명 대기({_prev_pend.get('qty')}주)에 "
f"새 불명 매도 {sell_qty}주 합산"
)
_new_qty += int(_prev_pend.get("qty") or 0)
_new_pre = max(_new_pre, int(_prev_pend.get("pre_hldg") or 0))
_pending_unknown_sells[stock_code] = {
"qty": _new_qty,
"pre_hldg": _new_pre,
"since": time.time(),
"since_mono": time.monotonic(),
"price": float(latest_price),
"avg_price": float(avg_price),
"swing_qty": int(_swing_basis),
"sell_pct": float(_stage_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_reason,
"market": market,
"prior_realized_net": _prior_realized_net,
}
# [하드닝 R2] 재기동에도 대조가 이어지도록 Redis 에 미러링(실패는 warning)
await _persist_unknown_sell_v2(stock_code, _pending_unknown_sells[stock_code])
# [하드닝 R9, H-B] 불명 경로도 청산 쿨다운 기록 — 추천 경로 _execute_sell 공용
# 쿨다운과 분할 신호 재매도를 막는다(전량형은 쿨다운 우회 규칙 그대로).
_recent_exits[stock_code] = time.time()
_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} — 다음 주기 재시도"
)
# [하드닝 R4] 연속 거부 집계 + 경보(계좌 단위 거부는 즉시) — 무한 무경보 재시도 방지
_note_swing_sell_reject_v2(stock_code, _mc, err)
return
_clear_swing_sell_reject_v2(stock_code)
order_id = order_resp.get("output", {}).get("ODNO", "")
# [2026-10-09 R1] profit_loss = 비용 차감 순손익(§5.1). loss_tracker 판정·
# TradeHistory.net_profit_loss 모두 이 값. 수익률은 기존 규약대로 gross
# (체결 확인 net_rate=False 와 동일 — 표시·저널 호환).
_pnl = _swing_net_pnl_v2(avg_price, latest_price, sell_qty, market, domestic)
profit_loss = _pnl["net_profit_loss"]
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,
# [2026-10-08] 시장가 주문 — 체결가는 아래 체결 확인이 채운다(그 전엔 NULL).
# total_amount·손익은 신호가 기준 추정치이며 확인 시 실제 체결로 덮어쓴다.
"executed_price": None,
"executed_quantity": sell_qty,
"total_amount": latest_price * sell_qty,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": _exit_reason,
"commission": _pnl["total_cost"],
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "swing",
})
_recent_exits[stock_code] = time.time()
# [하드닝 R9, §8.5-38 H-B] 결과 불명 대기 중 정상 접수된 매도(손절·전량)는 대기 기준 잔고에서
# 차감한다 — 안 하면 다음 대조의 (pre_hldg − KIS 보유)에 이 매도분이 섞여 불명 체결로 이중 기록.
_pend_now = _pending_unknown_sells.get(stock_code) if domestic else None
if _pend_now is not None:
_pend_now["pre_hldg"] = max(0, int(_pend_now.get("pre_hldg") or 0) - int(sell_qty))
logger.warning(
f"[SwingExit] {stock_code}: 결과 불명 대기 중 정상 매도 {sell_qty}주 접수 — "
f"대기 기준 잔고 → {_pend_now['pre_hldg']}주"
)
await _persist_unknown_sell_v2(stock_code, _pend_now)
# [2026-10-08] 실제 체결가·체결수량 확인 → TradeHistory 체결가·금액·순손익 보정
# (백그라운드, db_worker 큐 경유). 이전엔 신호가(latest_price)가 그대로 체결가로
# 남았다. 국내만 — 해외 체결조회 경로 없음(US_TRADING_ENABLED=False).
if domestic and order_id:
_schedule_swing_sell_fill_check_v2(
order_id=order_id, stock_code=stock_code, stock_name=stock_name,
sell_qty=int(sell_qty), ref_price=float(latest_price),
entry_price=float(avg_price), market=market,
)
# 포트폴리오 화면 즉시 반영 (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 갱신
# [하드닝 R3] 판정 기준은 주문 상한 적용 전 스윙 보유(_swing_basis)
if _is_full_close:
# 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, _stage_sell_pct, _swing_left, 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": _swing_left,
"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": _swing_left,
"entry_price": avg_price,
"current_price": latest_price,
"strategy_name": "swing",
"status": "active",
"entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)",
})
await enqueue_active_trade_upsert(_at_data)
# LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지)
if _is_full_close:
# [H-1 2026-10-02] 전량 청산 → 이 포지션의 실시세 캐시 폐기
# (재매수 포지션에 예전 보유분 가격이 대체가로 쓰이지 않게)
_last_live_prices.pop(stock_code, None)
_last_atr.pop(stock_code, None)
is_loss = _position_is_loss_v2(_prior_realized_net, profit_loss) # 순손익 기준 — 왕복 비용 이내 이익도 손실(§5.1)
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_reason,
})
logger.info(
f"[SwingExit] ✅ 청산 완료: {stock_code} {stock_name} "
f"수익률={profit_loss_rate:.1f}% 이유={_exit_reason}"
)
await asyncio.shield(_sell_and_record_v2())
except Exception as e:
logger.error(f"[SwingExit] 종목 처리 오류 ({holding.stock_code}): {e}", exc_info=True)
# [하드닝 R4] 종목별 연속 예외 집계 + 임계 경보(이전엔 로그만 → '정상' 집계)
_err_code = str(getattr(holding, "stock_code", ""))
_run_errored[_err_code] = f"{type(e).__name__}: {e}"
_note_exit_symbol_error_v2(_err_code, _run_errored[_err_code])
except Exception as e:
logger.error(f"[SwingExit] 전체 오류: {e}", exc_info=True)
# [하드닝 R4] 전체 오류는 실패로 기록 — 스케줄러가 _record_exit_failure_v2 로 집계한다
_run_fatal = f"{type(e).__name__}: {e}"
finally:
_record_exit_run_v2(_run_holding_codes, _run_errored, _run_fatal)
return executed_exits