- 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>
2565 lines
139 KiB
Python
2565 lines
139 KiB
Python
"""스윙 트레이딩 청산 서비스
|
||
|
||
기존 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
|