Files
auto-stock/backend/app/services/trade_executor.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

3336 lines
183 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""자동매매 실행 엔진 (trade_executor.py)
흐름 요약:
execute_auto_trade_background() ← 추천 목록 수신
└─ execute_auto_trade() ← 종목별 공통 필터 (가격·MDD·신호 등)
├─ _execute_buy() ← 매수 로직 (급등체크→트렌드→포지션사이징→주문)
└─ _execute_sell() ← 매도 로직 (중복체크→주문→DB기록)
└─ _verify_and_sync_execution() ← 체결 확인·슬리피지 기록·부분체결 재주문
"""
import asyncio
import logging
import time
from decimal import Decimal
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_, or_
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.models.models import TradeHistory, Portfolio
from app.services.korea_investment_api import korea_investment_api
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
from app.services.market_analyzer import check_market_regime, _get_kst_now
from app.services.mean_reversion_strategy import (
MarketRegime, calculate_position_size_v2,
check_exit_signal, calculate_rsi, calculate_bollinger, calculate_atr,
)
from app.services.technical_indicators import (
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema,
)
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음)
from app.services.sizing_core import (
amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction,
remaining_position_room,
)
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
from app.services.correlation_filter import validate_correlation_filter
from app.services.exchange_detector import is_us_stock, detect_exchange, get_market_info
from app.core.timeutil import now_kst
from app.services.trade_helpers import (
check_opening_range_volatility, check_surge_prevention, check_trend_filter,
calculate_cooldown_minutes, get_daily_cutoff_utc, get_leverage_check_keywords,
is_leverage_etf, calculate_profit_loss_rate, calculate_atr_based_stops,
build_kis_order_id, calculate_trade_costs,
)
from app.core.log_throttle import warn_throttled # noqa: E402
logger = logging.getLogger(__name__)
# ── 잔량 추적 해석 (CLAUDE.md §8.5 항목4) ─────────────────────────────────────
def _resolve_remaining_pct_v2(stock: dict, holding=None) -> tuple[float, bool]:
"""remaining_pct 와 '잔량 추적 가능 여부' 를 함께 반환.
기존 `stock.get("remaining_pct", 1.0) or 1.0` 은 '추적 소스 없음(None)' 과
'실제로 잔량 100%' 를 구분하지 못했다. ActiveTrade 행이 없는 보유분
(브로커 잔고 sync 로 생긴 Portfolio 행 / legacy 행)은 매 사이클 잔량 100%로
간주되어 SELL_PARTIAL(50%)이 무한 반복됐다.
Args:
stock: 종목 dict (remaining_pct 주입 대상)
holding: Portfolio ORM (현재 판정에 미사용 — 향후 확장용, 시그니처 고정)
Returns:
(remaining_pct, is_tracked)
- is_tracked=False → 잔량을 신뢰할 수 없음. 호출측은 부분매도를 스킵하고
전량 청산 신호만 채택해야 한다. 값은 기존 동작과 동일한 1.0 을 준다.
- remaining_pct 는 항상 0 < x <= 1.0 범위로 클램프.
"""
raw = stock.get("remaining_pct") if isinstance(stock, dict) else None
if raw is None:
return 1.0, False
try:
val = float(raw)
except (TypeError, ValueError):
return 1.0, False
if not (0.0 < val <= 1.0):
# 0 이하(전량 소진) 또는 1 초과(오염) → 추적 불가로 간주
return 1.0, False
return val, True
# ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
_order_semaphores: dict = {}
_SEMAPHORE_MAX_SIZE = 200
def _cleanup_semaphores() -> None:
"""세마포어 딕셔너리 크기가 임계값 초과 시 미사용 항목 제거.
[동작] sem._value == 1 이면 현재 아무도 잡고 있지 않은 상태(unlock).
장기 운영 시 거래된 적 있는 모든 종목 코드가 누적되는 것을 방지.
"""
if len(_order_semaphores) <= _SEMAPHORE_MAX_SIZE:
return # 임계값 이하 → 정리 불필요
to_delete = [
code for code, sem in _order_semaphores.items()
if sem._value == 1 # unlocked = 현재 사용 중 아님
]
for code in to_delete:
_order_semaphores.pop(code, None)
if to_delete:
logger.debug(f"[SEMAPHORE_CLEANUP] {len(to_delete)}개 세마포어 정리 (잔여: {len(_order_semaphores)})")
def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore:
"""종목별 세마포어 반환. 없으면 생성. 크기 초과 시 미사용 항목 정리.
[동작] 동일 종목에 대해 동시에 2개 이상의 주문이 발생하지 않도록
asyncio.Semaphore(1) 으로 상호 배제. 코드 진입 시 lock → 완료 시 release.
"""
if stock_code not in _order_semaphores:
_cleanup_semaphores() # 200개 초과 시 미사용 세마포어 정리
_order_semaphores[stock_code] = asyncio.Semaphore(1)
return _order_semaphores[stock_code]
# ── Redis 분산락 — 중복 주문 완전 방지 (TTL 60초) ────────────────────────────
_ORDER_LOCK_TTL = 60 # seconds
# [M-2 5회차 2026-10-02] 프로세스 내 (종목, 방향) in-flight 표 — Redis 락의 1차 방어.
# 기존 주석은 "Redis 장애 시 세마포어가 2차 방어"라고 했지만 종목별 Semaphore(1) 은 **직렬화**만
# 한다 — 스윙 청산·DT·_execute_sell·수동 매도가 같은 종목 SELL 을 순서대로 **둘 다** 전송했다.
# 여기에 이미 있으면(같은 프로세스에서 진행 중) Redis 결과와 무관하게 거부한다.
# 값 = 획득 시각(monotonic). release 누락에 대비해 Redis 와 같은 TTL 이 지나면 무효.
_inflight_order_locks: dict = {}
def _local_order_lock_held_v2(key: tuple) -> bool:
ts = _inflight_order_locks.get(key)
if ts is None:
return False
if time.monotonic() - ts > _ORDER_LOCK_TTL:
logger.warning(f"[order_lock] 로컬 in-flight {key} TTL 초과 — release 누락으로 보고 해제")
_inflight_order_locks.pop(key, None)
return False
return True
async def _acquire_order_lock(stock_code: str, order_type: str) -> bool:
"""주문 락 획득 (프로세스 내 in-flight 표 + Redis SETNX). 성공 시 True.
[동작]
1. 같은 프로세스에서 같은 (종목, 방향) 주문이 진행 중이면 False — Redis 상태와 무관.
확인과 등록 사이에 await 가 없어 원자적이다(단일 이벤트 루프).
2. Redis SET NX + EX(TTL 60초) — 다른 프로세스/워커와의 중복 방지.
- 키가 이미 있으면 False(로컬 등록도 취소) → 호출측 SKIP.
- Redis 미연결·예외면 True(로컬 표만으로 진행). 여기서 False 로 막으면 Redis 장애 동안
손절 매도까지 전부 멈춘다(§8.1-11). 단일 프로세스 운영이므로 로컬 표로 중복이 막힌다.
"""
_local_key = (stock_code, str(order_type).upper())
if _local_order_lock_held_v2(_local_key):
logger.warning(f"[order_lock] {stock_code} {order_type} — 같은 프로세스에서 주문 진행 중, 중복 차단")
return False
_inflight_order_locks[_local_key] = time.monotonic()
try:
from app.core.redis import get_redis
client = get_redis()
if not client:
return True # Redis 미연결 → 로컬 in-flight 표로 보호
key = f"order_lock:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
if result is True:
return True
_inflight_order_locks.pop(_local_key, None) # 다른 프로세스가 보유 — 로컬 등록 취소
return False
except Exception as e:
logger.warning(f"[order_lock] Redis 획득 오류 (로컬 in-flight 표로 진행): {e}")
return True
async def _release_order_lock(stock_code: str, order_type: str) -> None:
"""주문 락 해제 — 로컬 in-flight 표 먼저(항상), 그다음 Redis 키.
[동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제.
Redis DELETE 가 실패하면 키가 TTL(60초)까지 남아 다른 경로의 다음 주문이 최대 1사이클
밀릴 수 있다(알려진 한계 — 로컬 표는 이미 해제됨).
"""
_inflight_order_locks.pop((stock_code, str(order_type).upper()), None)
try:
from app.core.redis import delete_key
await delete_key(f"order_lock:{stock_code}:{order_type}")
except Exception:
pass # 해제 실패 시 TTL(60초) 만료 후 자동 해제됨
# ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ───────────────────
# 재시도 대상 KIS msg_cd (레이트리밋). 업무 거부(잔고 부족·호가 오류·계좌 오류 등)는
# 재시도해도 같은 결과이고, 반복 주문이 일일 한도·쿨다운 판정을 오염시키므로 제외한다.
# [M-2 3회차 2026-10-02] KIS 게이트웨이 토큰 무효(EGW00121/EGW00123)도 재시도 대상.
# 게이트웨이 단계 거부라 주문이 접수되지 않은 것이 확실하고, korea_investment_api 가 이 코드에서
# 토큰을 재발급하므로 재시도하면 같은 사이클에 주문이 나간다(손절 매도가 1사이클 지연되던 문제).
from app.services.korea_investment_api import KIS_TOKEN_INVALID_MSG_CODES as _KIS_TOKEN_INVALID
_RETRYABLE_ORDER_MSG_CDS = frozenset({
"EGW00201", # 초당 거래건수 초과
*_KIS_TOKEN_INVALID,
})
# [2026-10-02 r4] safe_place_order(_v2) 의 rt_cd "-1" 응답에 붙는 msg_cd. DT 가 이 값으로
# "주문이 KIS 에 전송됐는지"를 구분한다(전송 전 실패는 재시도 안전, 전송 후 불명은 체결 조회 필요).
# korea_investment_api.place_order 가 자체 예외 처리에서 붙이는 KIS_PRESEND_CONNECT·
# KIS_PRESEND_CIRCUIT_OPEN·TOKEN_UNAVAILABLE·KIS_TRANSPORT_UNKNOWN 과 같은 체계다.
SAFE_ORDER_MSG_LOCK_BUSY = "ORDER_LOCK_BUSY"
SAFE_ORDER_MSG_PRESEND_EXCEPTION = "KIS_PRESEND_EXCEPTION"
SAFE_ORDER_MSG_TRANSPORT_UNKNOWN = "KIS_TRANSPORT_UNKNOWN"
# [H-3 5회차 2026-10-02] '전송 전' 실패 = KIS 에 주문이 가지 않은 것이 확실 → 재전송 안전.
from app.services.korea_investment_api import KIS_PRESEND_TOKEN_UNAVAILABLE as _KIS_PRESEND_TOKEN
_PRESEND_ORDER_MSG_CDS = frozenset({
"KIS_PRESEND_CONNECT",
"KIS_PRESEND_CIRCUIT_OPEN",
_KIS_PRESEND_TOKEN, # TOKEN_UNAVAILABLE
"KIS_PRESEND_EXCEPTION", # SAFE_ORDER_MSG_PRESEND_EXCEPTION
"ORDER_LOCK_BUSY", # SAFE_ORDER_MSG_LOCK_BUSY
})
def _is_retryable_order_failure(result: dict) -> bool:
"""주문 응답이 **재전송해도 안전한** 실패인지. rt_cd "0"(성공)은 호출부에서 먼저 걸러진다.
[H-3 5회차] 재전송은 KIS 에 주문이 가지 않았거나 게이트웨이에서 거부된 것이 확실한 경우만:
- 레이트리밋(EGW00201)·토큰 무효(EGW00121/123) — 게이트웨이 거부
- 전송 전 실패(KIS_PRESEND_*·TOKEN_UNAVAILABLE·ORDER_LOCK_BUSY)
rt_cd "-1" 이라도 `KIS_TRANSPORT_UNKNOWN`·msg_cd 없음·응답 없음은 **전송 후 결과 불명**이라
재전송하면 이중 체결(4% 초과 매수·과다 매도)이 난다 → 재시도 대상이 아니다
(`_is_order_outcome_unknown_v2` 로 따로 판정). rt_cd "1" 등 업무 거부도 재시도하지 않는다.
"""
if not isinstance(result, dict) or not result:
return False
cd = str(result.get("msg_cd") or "")
return cd in _RETRYABLE_ORDER_MSG_CDS or cd in _PRESEND_ORDER_MSG_CDS
def _is_order_outcome_unknown_v2(result) -> bool:
"""주문이 KIS 에 접수됐는지 알 수 없는 응답인지(전송 후 불명).
응답 없음/빈 dict, 또는 rt_cd "-1" 인데 재전송 안전 코드가 아닌 경우
(KIS_TRANSPORT_UNKNOWN·msg_cd 없음).
"""
if not isinstance(result, dict) or not result:
return True
if str(result.get("rt_cd")) != "-1":
return False
return not _is_retryable_order_failure(result)
async def _place_order_with_retry(
is_domestic: bool,
stock_code: str,
order_type: str,
quantity: int,
price: float,
exchange_code: str = "NASD",
max_retries: int = 3,
) -> dict:
"""주문 실행 with exponential backoff. 시스템 오류에만 재시도.
[동작]
1. 국내/해외 분기 후 KIS REST API 호출
2. rt_cd "0"(성공) → 즉시 반환
3. 업무 거부(rt_cd "1" 등, 레이트리밋 msg_cd 제외) → 재시도 없이 즉시 반환
4. 네트워크/시스템 오류(rt_cd "-1")·레이트리밋: 1초→2초→4초 대기 후 최대 max_retries회 재시도
5. 최대 재시도 소진 시 last_result(마지막 응답) 반환
"""
last_result: dict = {}
for attempt in range(max_retries):
try:
if is_domestic:
# 국내 주식 주문 (KIS TTTC0012U / VTTC0012U)
result = await korea_investment_api.place_order(stock_code, order_type, quantity, price)
else:
# 해외 주식 주문 (KIS TTTT1002U 등)
result = await korea_investment_api.place_overseas_order(
stock_code, order_type, quantity, float(price), exchange_code
)
# [H-1 2026-10-02] KIS 는 rt_cd "0" 만 접수 성공이다. 기존엔 "1" 을
# PENDING(접수)으로 보고 즉시 반환, 그 밖의 코드는 업무 거부(잔고 부족·
# 호가 오류 등)까지 3회 재시도했다. 이제 재시도는 네트워크/시스템 오류
# (래퍼가 만든 rt_cd "-1")와 레이트리밋(msg_cd)만 — 업무 거부는 즉시 반환.
if result.get("rt_cd") == "0":
return result
last_result = result
if _is_order_outcome_unknown_v2(result):
# [H-3 5회차] 전송 후 결과 불명 — 재전송 금지. 당일 주문내역에 보이면 그 결과를,
# 아니면 불명 응답을 그대로 돌려준다(호출부가 PENDING 으로 기록하고 잔고 동기화로
# 확인). 주문내역 조회 실패도 []/None 이라 '없음'과 구분할 수 없으므로,
# 못 찾았다고 재전송하지 않는다.
if is_domestic:
_dup = await _find_recent_order(stock_code, order_type, quantity)
if _dup:
logger.warning(
f"[ORDER_DUP] {stock_code} {order_type} 결과 불명이나 주문 접수 확인됨 "
f"(odno={_dup.get('odno')}) → 재전송 안 함"
)
return _dup
logger.error(
f"[ORDER_UNKNOWN] {stock_code} {order_type} {quantity}주 — 전송 후 결과 불명 "
f"(msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}). 이중 체결 방지로 "
f"재전송하지 않음 — 잔고 동기화로 확인"
)
return result
if not _is_retryable_order_failure(result):
logger.warning(
f"[ORDER_REJECT] {stock_code} {order_type} KIS 거부 — 재시도 안 함 "
f"(rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')})"
)
return result
if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) # 1s → 2s → 4s (exponential backoff)
logger.warning(
f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} "
f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait)
except Exception as e:
# 네트워크 타임아웃 등 예외 → 재시도 대상
# [2026-09-23] 단, KIS가 주문을 접수한 뒤 응답만 못 받은 경우가 있으므로
# 재시도 전에 당일 체결 내역으로 중복 여부를 확인한다(멱등성 보강).
# [H-3 5회차] place_order 호출 중 예외 = 전송 여부 불명 → 재전송 금지(위와 동일 정책).
last_result = {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN,
"msg1": str(e), "odno": None}
if is_domestic:
_dup = await _find_recent_order(stock_code, order_type, quantity)
if _dup:
logger.warning(
f"[ORDER_DUP] {stock_code} {order_type} 예외 발생했으나 주문 접수 확인됨 "
f"(odno={_dup.get('odno')}) → 재전송 안 함"
)
return _dup
logger.error(
f"[ORDER_UNKNOWN] {stock_code} {order_type} {quantity}주 — 주문 중 예외로 결과 불명 "
f"({type(e).__name__}: {e}). 이중 체결 방지로 재전송하지 않음 — 잔고 동기화로 확인"
)
return last_result
return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None}
async def _find_recent_order(stock_code: str, order_type: str, quantity: int) -> dict | None:
"""직전 주문이 실제로 KIS에 접수됐는지 당일 주문체결 내역으로 확인.
타임아웃/연결 끊김으로 응답을 못 받았지만 주문은 접수된 경우, 그대로 재시도하면
중복 체결이 된다. 최근 2분 내 같은 종목·매매구분·수량 주문이 있으면 그 주문을 반환한다.
조회 실패 시 None (판단 불가 → 호출측의 기존 재시도 로직을 따른다).
"""
try:
rows = await korea_investment_api.get_todays_orders(stock_code)
except Exception as e:
logger.warning(f"[ORDER_DUP] 주문내역 조회 실패 ({stock_code}): {e}")
return None
if not rows:
return None
_now = datetime.now()
_side = "02" if order_type.upper() == "BUY" else "01" # KIS: 01=매도, 02=매수
for row in rows:
try:
if row.get("pdno") != stock_code:
continue
if row.get("sll_buy_dvsn_cd") not in (_side, None, ""):
continue
if int(row.get("ord_qty", 0) or 0) != int(quantity):
continue
_t = str(row.get("ord_tmd") or "") # HHMMSS
if len(_t) == 6:
_ordered = _now.replace(
hour=int(_t[:2]), minute=int(_t[2:4]), second=int(_t[4:]), microsecond=0
)
if (_now - _ordered).total_seconds() > 120:
continue
return {
"rt_cd": "0",
"msg1": "기존 주문 확인(중복 방지)",
# [L-5 5회차] 같은 종목·방향·수량의 '아무' 주문일 수 있다 — 자기 주문이라는
# 보장이 없으므로 이 ODNO 로 취소하지 않는다(_execute_sell 이 이 표식을 본다).
"_matched_by_lookup": True,
"odno": row.get("odno"),
"output": {"ODNO": row.get("odno"), "ord_prc": row.get("ord_unpr")},
}
except Exception:
continue
return None
# ── [C1] 공통 주문 래퍼 — 분산락+세마포어 보호 (daytrading/swing에서 import) ──
async def safe_place_order(
stock_code: str,
order_type: str,
quantity: int,
price: float | None = None,
is_domestic: bool = True,
exchange_code: str = "NASD",
) -> dict:
"""Redis 분산락 + 세마포어로 보호된 주문 실행.
daytrading/exit_service, swing/exit_service에서 korea_investment_api.place_order()
대신 이 함수를 사용하여 이중 주문을 방지합니다.
"""
if not await _acquire_order_lock(stock_code, order_type):
logger.warning(f"[SAFE_ORDER] {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)")
return {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_LOCK_BUSY, "msg1": "order_lock_busy"}
_sent = False # place_order 호출 진입 여부 — 예외 시 전송 전/후 구분
try:
async with _get_order_semaphore(stock_code):
_sent = True
if is_domestic:
result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
)
else:
result = await korea_investment_api.place_overseas_order(
symbol=stock_code,
order_type=order_type,
quantity=quantity,
price=price or 0,
exchange_code=exchange_code,
)
return result or {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN, "msg1": "no_response"}
except Exception as e:
_cd = SAFE_ORDER_MSG_TRANSPORT_UNKNOWN if _sent else SAFE_ORDER_MSG_PRESEND_EXCEPTION
logger.error(f"[SAFE_ORDER] {stock_code} {order_type} 실패({_cd}): {e}")
return {"rt_cd": "-1", "msg_cd": _cd, "msg1": str(e)}
finally:
await _release_order_lock(stock_code, order_type)
# ── [멀티테넌트 1단계 §5.8] 사용자 스코프 주문 락/래퍼 ────────────────────────
# 기존 safe_place_order/_acquire_order_lock/_auto_cancel_if_pending는 시그니처
# 불변 유지. 사용자별 계좌(ctx.kis)로 주문하는 경로는 아래 _v2 함수를 사용한다.
async def _acquire_order_lock_v2(user_id, stock_code: str, order_type: str) -> bool:
"""Redis 분산락 획득 — 사용자 네임스페이스 키(`order_lock:{user_id}:{code}:{type}`).
구조는 `_acquire_order_lock`과 동일(SETNX+TTL 60초). legacy 컨텍스트(user_id가
None 또는 legacy owner id)도 이 함수를 쓰면 키에 user_id가 섞이므로, legacy
경로는 기존 `_acquire_order_lock`(user_id 없는 키)을 그대로 사용해야 한다.
"""
try:
from app.core.redis import get_redis
client = get_redis()
if not client:
return True
key = f"order_lock:{user_id}:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
return result is True
except Exception as e:
# 통과 근거는 _acquire_order_lock 과 동일(단일 프로세스 + 종목별 세마포어 2차 방어,
# 차단 시 청산까지 정지).
logger.warning(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스 — 세마포어가 2차 방어): {e}")
return True
async def _release_order_lock_v2(user_id, stock_code: str, order_type: str) -> None:
"""Redis 분산락 해제 — `_acquire_order_lock_v2`와 짝을 이루는 사용자 네임스페이스 키."""
try:
from app.core.redis import delete_key
await delete_key(f"order_lock:{user_id}:{stock_code}:{order_type}")
except Exception as _swallowed:
logger.warning("[order_lock_v2] 락 해제 실패(TTL 만료까지 유지): %s", _swallowed)
async def safe_place_order_v2(
ctx, # UserContext (순환 임포트 회피 — 타입은 app.services.tenant.context.UserContext)
stock_code: str,
order_type: str,
quantity: int,
price: float | None = None,
is_domestic: bool = True,
exchange_code: str = "NASD",
) -> dict:
"""`safe_place_order`의 사용자 스코프 버전 (설계 문서 §5.8).
- `ctx.is_legacy`면 기존 전역 경로(`_acquire_order_lock`/`_release_order_lock`,
`_get_order_semaphore(stock_code)`)를 그대로 사용 — swing/daytrading exit_service
등 기존 스케줄러가 잡는 락과 같은 키여야 같은 계좌·종목 동시 매도가 직렬화된다.
사용자 컨텍스트(`is_legacy=False`)만 사용자 네임스페이스 키(`{user_id}:{code}`)를 쓴다.
- 세마포어: `_get_order_semaphore(f"{ctx.user_id}:{stock_code}")` — 기존 함수 재사용,
키 문자열만 사용자 네임스페이스로 바꿈(시그니처 불변).
- Redis 락: `_acquire_order_lock_v2`/`_release_order_lock_v2` (legacy는 v1 그대로).
- 주문 실행: `ctx.kis.place_order(...)`/`ctx.kis.place_overseas_order(...)`.
- 반환 형식은 `safe_place_order`와 동일.
"""
if ctx.is_legacy:
semaphore_key = stock_code
lock_acquired = await _acquire_order_lock(stock_code, order_type)
else:
semaphore_key = f"{ctx.user_id}:{stock_code}"
lock_acquired = await _acquire_order_lock_v2(ctx.user_id, stock_code, order_type)
if not lock_acquired:
logger.warning(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)")
return {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_LOCK_BUSY, "msg1": "order_lock_busy"}
_sent = False
try:
async with _get_order_semaphore(semaphore_key):
_sent = True
if is_domestic:
result = await ctx.kis.place_order(
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
)
else:
result = await ctx.kis.place_overseas_order(
symbol=stock_code,
order_type=order_type,
quantity=quantity,
price=price or 0,
exchange_code=exchange_code,
)
return result or {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN, "msg1": "no_response"}
except Exception as e:
_cd = SAFE_ORDER_MSG_TRANSPORT_UNKNOWN if _sent else SAFE_ORDER_MSG_PRESEND_EXCEPTION
logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패({_cd}): {e}")
return {"rt_cd": "-1", "msg_cd": _cd, "msg1": str(e)}
finally:
if ctx.is_legacy:
await _release_order_lock(stock_code, order_type)
else:
await _release_order_lock_v2(ctx.user_id, stock_code, order_type)
async def _auto_cancel_if_pending_v2(ctx, order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""`_auto_cancel_if_pending`의 사용자 스코프 버전 — `ctx.kis.cancel_order`를 사용."""
await asyncio.sleep(delay)
try:
result = await ctx.kis.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0":
logger.info(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else:
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e:
logger.warning(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {type(e).__name__}: {e}")
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""delay초 후에도 PENDING 상태면 자동 취소.
[동작] 주문 후 delay(기본 60초)를 기다린 뒤 KIS 취소 API 호출.
이미 체결됐다면 취소 실패(msg1에 오류 메시지) → DEBUG 로그만 남기고 무시.
호출 측에서 asyncio.create_task() 로 백그라운드 실행.
"""
await asyncio.sleep(delay) # 60초 대기 후 취소 시도
try:
result = await korea_investment_api.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0":
logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else:
# 이미 체결됐거나 취소 불가 상태 → 무시
logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e:
logger.warning(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {e}")
# ── 계좌 정보 30초 인메모리 캐시 ──────────────────────────────────────────────
_account_cache: dict = {"data": None, "ts": 0.0}
_ACCOUNT_CACHE_TTL = 30 # seconds
def _get_cached_account() -> tuple | None:
"""(available_cash, total_assets) 30초 캐시. 만료 시 None 반환.
[동작] monotonic clock으로 TTL 검사. 만료되지 않았으면 캐시 반환,
만료됐거나 데이터가 없으면 None → 호출측에서 portfolio_cache 재조회.
"""
if _account_cache["data"] and time.monotonic() - _account_cache["ts"] < _ACCOUNT_CACHE_TTL:
return _account_cache["data"]
return None
def _set_account_cache(available_cash: float, total_assets: float) -> None:
"""계좌 캐시 갱신. 현재 시각(monotonic)을 타임스탬프로 저장."""
_account_cache["data"] = (available_cash, total_assets)
_account_cache["ts"] = time.monotonic()
_account_cache["deducted"] = False
def _get_cached_account_v2() -> tuple | None:
"""`_get_cached_account` + '매수 차감으로 현금이 0 이 된 캐시' 도 신뢰.
기존 호출부는 현금 0 캐시를 불신(무한 0주 루프 방지)하고 portfolio_cache/KIS 로
재조회한다. 그러나 매수 접수로 차감된 0 은 진짜 0 이고, 재조회 소스(portfolio_cache·
KIS 예수금)는 아직 그 매수를 반영하지 않았을 수 있다 → 차감된 캐시는 TTL 동안 그대로 쓴다.
"""
cached = _get_cached_account()
if cached is None:
return None
if cached[0] > 0 or _account_cache.get("deducted"):
return cached
return None
def _deduct_account_cache_v2(amount: float, available_cash: float, total_assets: float) -> None:
"""[H-2 2026-10-02] 매수 접수 성공 시 30초 계좌 캐시의 현금을 즉시 차감.
기존엔 캐시가 TTL 동안 매수 전 현금을 그대로 돌려줘, 같은 사이클의 다음 종목이
현금 95%(MAX_TOTAL_CAPITAL_USAGE)·스윙 70%(SWING_MAX_CAPITAL_RATIO) 가드를 매수
전 기준으로 통과했다. 총자산은 현금→주식 이동이라 그대로 둔다.
캐시가 없거나 만료됐으면 이번 판정에 쓴 값(available_cash/total_assets)에서 차감해 심는다.
"""
try:
amt = max(0.0, float(amount))
except (TypeError, ValueError):
amt = 0.0
cached = _get_cached_account()
base_cash, base_assets = cached if cached is not None else (available_cash, total_assets)
_account_cache["data"] = (max(0.0, float(base_cash) - amt), float(base_assets))
_account_cache["ts"] = time.monotonic()
_account_cache["deducted"] = True
# ── [M-3 2026-10-02] 사이클 내 매수 성공 종목 인메모리 누적 ───────────────────
# Portfolio 행은 db_worker 큐 경유로 비동기 반영되므로 같은 사이클(및 직후 사이클)의
# 보유수·섹터·레버리지·전략배분 판정이 방금 산 종목을 보지 못했다.
# 매수 접수 성공 종목을 TTL 동안 기억해 판정에 합산한다(이미 Portfolio 에 보이면 중복 제외).
_recent_cycle_buys: dict = {} # stock_code -> {"ts", "stock_name", "quantity", "price"}
_RECENT_CYCLE_BUY_TTL_SEC = 300 # db_worker 반영 지연 흡수용(전략 파라미터 아님)
def _note_cycle_buy_v2(stock_code: str, stock_name: str, quantity: int, price: float) -> None:
_recent_cycle_buys[stock_code] = {
"ts": time.monotonic(),
"stock_name": stock_name or "",
"quantity": int(quantity or 0),
"price": float(price or 0),
}
def _recent_cycle_buys_v2() -> dict:
"""TTL 이내 매수 성공 종목 (만료분 정리 후 사본 반환)."""
now = time.monotonic()
for code in [c for c, v in _recent_cycle_buys.items()
if now - v.get("ts", 0) > _RECENT_CYCLE_BUY_TTL_SEC]:
_recent_cycle_buys.pop(code, None)
return dict(_recent_cycle_buys)
def _forget_cycle_buy_v2(stock_code: str, reason: str) -> None:
"""[M-4 3회차] 미체결·취소 확정 매수는 사이클 매수 누적에서 뺀다(보유수 과대 집계 방지)."""
if _recent_cycle_buys.pop(stock_code, None) is not None:
logger.warning(f"[CYCLE_BUY] {stock_code}: 사이클 매수 누적에서 제외 — {reason}")
def _domestic_recent_cycle_buy_codes_v2() -> set:
"""국내 보유수 집계용 — 해외(USD) 매수는 MAX_HOLDINGS_COUNT 대상이 아니다."""
return {c for c in _recent_cycle_buys_v2().keys() if c and not is_us_stock(c)}
def _domestic_recent_swing_buy_codes_v2() -> set:
"""[M-3 4회차] 사이클 매수 누적 중 국내 '스윙 소유' 매수 (일일 스윙 한도 집계용)."""
return {
c for c, v in _recent_cycle_buys_v2().items()
if c and not is_us_stock(c)
and (v.get("strategy_type") in (None, *_SWING_OWNER_STRATEGY_TYPES))
}
# ── [M-1 3회차 2026-10-02] 업무 거부 매수 종목 단기 쿨다운 (인메모리) ──────────
_order_reject_cooldown: dict = {} # stock_code -> monotonic ts
_eval_rotation_offset: int = 0 # [L-3 4회차] 신규 후보 평가 시작 오프셋(사이클마다 회전)
_account_reject_at: float | None = None # [L-2 4회차] 계좌 단위 거부 시각(monotonic)
def _account_reject_cooldown_remaining_v2() -> float:
"""계좌 단위 거부 쿨다운 남은 초. 없으면 0."""
global _account_reject_at
if _account_reject_at is None:
return 0.0
left = settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN * 60 - (time.monotonic() - _account_reject_at)
if left <= 0:
_account_reject_at = None
return 0.0
return left
def _note_order_reject_v2(stock_code: str, order_result: dict) -> None:
global _account_reject_at
if str(order_result.get("msg_cd") or "") in set(settings.KIS_ACCOUNT_REJECT_MSG_CODES or ()):
_account_reject_at = time.monotonic()
logger.error(
f"[ORDER_REJECT_COOLDOWN] 🚨 계좌 단위 거부 msg_cd={order_result.get('msg_cd')} "
f"({order_result.get('msg1')}) — {settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN:g}분간 "
f"전 종목 신규 매수 보류(청산은 계속). 계좌·앱키 상태 확인 필요"
)
_order_reject_cooldown[stock_code] = time.monotonic()
logger.warning(
f"[ORDER_REJECT_COOLDOWN] {stock_code}: KIS 업무 거부 "
f"(rt_cd={order_result.get('rt_cd')} msg_cd={order_result.get('msg_cd')} "
f"msg1={order_result.get('msg1')}) — {settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN:g}분간 재주문 안 함"
)
def _order_reject_cooldown_remaining_v2(stock_code: str) -> float:
"""남은 쿨다운(초). 없으면 0."""
ts = _order_reject_cooldown.get(stock_code)
if ts is None:
return 0.0
left = settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN * 60 - (time.monotonic() - ts)
if left <= 0:
_order_reject_cooldown.pop(stock_code, None)
return 0.0
return left
def _merge_recent_buys_into_holdings_v2(holdings: list) -> list:
"""보유 리스트에 '아직 Portfolio 에 안 보이는' 최근 매수 종목을 의사 행으로 덧붙인다."""
from types import SimpleNamespace
held = {getattr(h, "stock_code", None) for h in holdings}
merged = list(holdings)
for code, v in _recent_cycle_buys_v2().items():
if code in held:
continue
merged.append(SimpleNamespace(
stock_code=code,
stock_name=v["stock_name"],
quantity=v["quantity"],
current_price=v["price"],
average_price=v["price"],
strategy_type="swing",
))
return merged
def _leverage_exposure_v2(holdings: list, keywords) -> float | None:
"""보유 레버리지/인버스 ETF 평가액 합. 행 해석 실패 시 None(판정 불가)."""
total = 0.0
for h in holdings:
try:
h_name = getattr(h, "stock_name", "") or ""
if is_leverage_etf(h_name, keywords):
total += float(getattr(h, "quantity", 0) or 0) * float(
getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0
)
except Exception as e:
logger.warning(
f"[LEVERAGE_LIMIT] 보유 행 평가 실패({getattr(h, 'stock_code', '?')}): {e}"
)
return None
return total
def _parse_scale_v2(raw, name: str, stock_code: str) -> float:
"""배율 해석. None → 1.0(미주입), 0.0 은 0.0 그대로(진입 차단 의미 보존).
[M-4 2026-10-02] 기존 `float(x or 1.0)` 은 0.0 을 1.0 으로 바꿔 국면/플레이북의
'진입 차단(0배)'을 무력화했다. 해석 불가 값은 안전한 쪽(0.0 = 매수 안 함) + warning.
"""
if raw is None:
return 1.0
try:
val = float(raw)
except (TypeError, ValueError):
logger.warning(f"[POSITION_SCALE] {stock_code}: {name}={raw!r} 해석 불가 — 0 으로 간주(매수 차단)")
return 0.0
if val != val: # NaN
logger.warning(f"[POSITION_SCALE] {stock_code}: {name}=NaN — 0 으로 간주(매수 차단)")
return 0.0
return max(0.0, min(1.0, val))
# ── 지정가 조정 — 체결률 향상 ──────────────────────────────────────────────────
def _resolve_sell_order_price_v2(
stock_code: str, current_price: float, is_domestic: bool, full_exit: bool, profit_loss_rate: float,
) -> tuple:
"""[H-1 4회차] (기준가, 주문가 또는 None=시장가, 방식) — 순수 판정.
- 해외: 기존 지정가(현재가 −0.5%) 유지
- 국내 전량 청산 또는 손실 청산: 시장가 (미체결로 수량을 잠그면 후속 손절이 거부된다)
- 국내 부분 익절: 'limit_refresh' — 호출부가 주문 직전 현재가로 지정가를 재산정
"""
if not is_domestic:
return current_price, _adjust_order_price(current_price, "SELL", is_domestic), "limit_overseas"
if full_exit or profit_loss_rate < 0:
return current_price, None, "market"
return current_price, None, "limit_refresh"
async def _fresh_domestic_price_v2(stock_code: str) -> float:
"""주문 직전 국내 현재가. 실패/0 이면 0.0(호출부가 시장가로 폴백)."""
try:
data = await korea_investment_api.get_current_price(stock_code)
return float(((data or {}).get("output") or {}).get("stck_prpr") or 0)
except Exception as e:
logger.warning(f"[SELL] {stock_code}: 주문 직전 현재가 조회 실패 — 시장가로 폴백: {e}")
return 0.0
def _adjust_order_price(price: float, order_type: str, is_domestic: bool = True) -> float:
"""BUY +0.5% / SELL -0.5% 지정가 조정.
국내: 원화 정수 반환 (KIS는 원화 단위 정수만 허용).
해외: 소수점 2자리 반환 (USD 단위).
"""
if order_type == "BUY":
adjusted = price * 1.005
else:
adjusted = price * 0.995
if is_domestic:
return float(int(adjusted)) # 원화 정수
return round(adjusted, 2) # USD 소수점 2자리
# ── Kelly Criterion 포지션 사이징 ─────────────────────────────────────────────
async def _calculate_kelly_position(
db: AsyncSession,
stock_code: str,
total_assets: float,
available_cash: float,
max_per_position: float = 0.04,
lookback: int = 20,
) -> float:
"""Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용).
f* = (p*b - (1-p)) / b
- p: 최근 N거래 승률 (이긴 거래 수 / 전체 거래 수)
- b: 평균이익률 / 평균손실률 (Payoff Ratio)
- Half-Kelly: f* / 2 → 변동성 감소 목적
[동작]
1. DB에서 최근 lookback회 매도 체결 기록 조회
2. 5거래 미만이면 통계 신뢰 부족 → 0.0 반환 (호출측에서 v2 fallback)
3. 반환값 = min(total_assets×clamped, available_cash, max_per_position 한도)
"""
try:
recent = await db.execute(
select(TradeHistory)
.where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "COMPLETED",
TradeHistory.profit_loss_rate.isnot(None),
)
.order_by(desc(TradeHistory.executed_at))
.limit(lookback)
)
trades = recent.scalars().all()
if len(trades) < 5:
return 0.0 # 데이터 부족 → v2 fallback
rates = [float(t.profit_loss_rate) for t in trades]
# [§8.5-2 통합 2026-09-27] Kelly 통계 수식은 sizing_core.half_kelly_fraction 로 이동
# (순수 계산). DB 조회·예외 처리·fallback 판단은 이 함수에 남는다.
# min_trades=5 미만 / 전승 / 전패 → None → 기존과 동일하게 0.0 반환(v2 fallback).
half_kelly = half_kelly_fraction(rates, min_trades=5)
if half_kelly is None:
return 0.0
clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용
# = min(총자산×clamped, 가용현금, 총자산×max_per_position) — 통합 전과 동일
return clamp_by_ratios(
total_assets * clamped, total_assets, [max_per_position], available_cash,
)
except Exception as e:
logger.warning(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}")
return 0.0
# ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ───────────────────────────────
async def _cancel_unfilled_order_v2(
order_id: str, stock_code: str, remaining_qty: int, org_no: str | None = None,
) -> bool:
"""[H-1 4회차] 미체결 잔량 취소(자기 ODNO 만). 성공 True, 실패는 error 로그 후 False.
[5회차] 인프라 `cancel_order_v2`(공식 order-rvsecncl 대조: KRX_FWDG_ORD_ORGNO 필수) 사용.
org_no 는 원주문 응답에서 추출해 전달하고, None 이면 v2 가 당일 주문내역에서 찾는다.
못 찾으면 미전송 `ORGNO_NOT_FOUND` → 취소 미확정이므로 호출부는 롤백하지 않는다.
"""
try:
result = await korea_investment_api.cancel_order_v2(
order_id, org_no=org_no, quantity=remaining_qty, stock_code=stock_code,
)
except Exception as e:
logger.error(f"[VERIFY_EXEC] {stock_code} 미체결 매도 취소 실패(예외) order_id={order_id}: {e} — 수동 취소 필요")
return False
if (result or {}).get("rt_cd") == "0":
logger.warning(f"[VERIFY_EXEC] {stock_code} 미체결 매도 {remaining_qty}주 취소 완료 (order_id={order_id})")
return True
logger.error(
f"[VERIFY_EXEC] {stock_code} 미체결 매도 취소 거부 order_id={order_id} "
f"rt_cd={(result or {}).get('rt_cd')} msg={(result or {}).get('msg1')} — 수동 취소 필요"
)
return False
def _extract_org_no_v2(order_result) -> str | None:
"""원주문 응답의 KRX_FWDG_ORD_ORGNO (취소에 필요). 추출 실패는 None — cancel_order_v2 가 조회."""
try:
from app.services.korea_investment_api import extract_order_org_no
return extract_order_org_no(order_result)
except Exception as e:
logger.warning(f"[SELL] 주문조직번호 추출 실패 — 취소 시 주문내역 조회로 대체: {e}")
return None
async def _rollback_active_trade_v2(stock_code: str, snapshot: dict | None, filled_qty: int) -> None:
"""[H-1 5회차] 취소된 지정가 부분 익절의 ActiveTrade 원복 (db_worker 큐).
- 0체결: 주문 전 스냅샷(remaining_pct·quantity·highest_since_entry·전략 필터) 그대로 upsert
→ 같은 익절 단계가 다음 사이클에 다시 판정된다. created_at·strategy_name 은 upsert 가
건드리지 않는다(행을 닫지 않았으므로 보존).
- 부분 체결: 잔량만 실제 체결 기준(스냅샷 수량 − 체결)으로 정정. remaining_pct 는 이미
진행한 단계값을 유지한다(같은 단계 재발동 방지).
"""
if not snapshot:
return
from app.services.db_worker import enqueue_active_trade_upsert
payload = {k: v for k, v in snapshot.items() if v is not None}
if filled_qty > 0:
payload = {k: v for k, v in payload.items() if k not in ("remaining_pct", "highest_since_entry")}
payload["quantity"] = max(0, int(snapshot.get("quantity") or 0) - int(filled_qty))
await enqueue_active_trade_upsert(payload)
logger.warning(
f"[VERIFY_EXEC] {stock_code} 취소된 지정가 익절 ActiveTrade 원복 "
f"(체결 {filled_qty}주, payload={payload})"
)
async def _mark_trade_cancelled_v2(order_id: str, db_trade_id: int, filled_qty: int = 0) -> None:
"""TradeHistory 를 CANCELLED 로 (db_worker 큐 경유)."""
fields = {"status": "CANCELLED", "executed_quantity": int(filled_qty)}
if db_trade_id:
from app.services.db_worker import enqueue_update_trade_history
await enqueue_update_trade_history(db_trade_id, fields)
else:
from app.services.db_worker import enqueue_update_trade_history_by_order_id
await enqueue_update_trade_history_by_order_id(order_id, fields)
async def _verify_and_sync_execution(
order_id: str,
stock_code: str,
stock_name: str,
ordered_quantity: int,
order_price: float,
order_type: str,
is_domestic: bool,
exchange_code: str,
db_trade_id: int,
delay: int = 3,
) -> None:
"""기존 시그니처 유지 래퍼 — 미체결 자동 취소 없음(시장가·주문종류 불명 경로).
[H-1 5회차] 미체결 취소는 `_verify_and_sync_execution_v2(cancel_unfilled_limit=True)` 로
**자기 ODNO 의 국내 지정가 부분 익절**에만 켠다. 시장가는 종가 단일가·VI·하한가에서 15초 넘게
미체결일 수 있고, 그 손절을 취소하면 청산이 사라진다.
"""
await _verify_and_sync_execution_v2(
order_id, stock_code, stock_name, ordered_quantity, order_price, order_type,
is_domestic, exchange_code, db_trade_id, delay=delay,
)
async def _verify_and_sync_execution_v2(
order_id: str,
stock_code: str,
stock_name: str,
ordered_quantity: int,
order_price: float,
order_type: str,
is_domestic: bool,
exchange_code: str,
db_trade_id: int,
delay: int = 3,
cancel_unfilled_limit: bool = False,
rollback_active_trade: dict | None = None,
order_org_no: str | None = None,
) -> None:
"""[H-1 5회차] cancel_unfilled_limit=True: 폴링 종료 시 국내 SELL 잔량이 살아 있으면 취소.
rollback_active_trade: 주문 전 ActiveTrade 스냅샷(전략 필터 포함). 0체결 취소 확정 시 그대로
upsert 해 remaining_pct·quantity·highest_since_entry 를 원복한다(행을 닫지 않는 부분 익절 전용).
주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 (부분 체결 자동 재주문 없음).
[동작 흐름]
1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보)
2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출
3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100
4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수)
- 완전 체결(filled_qty >= ordered_quantity) → status = "COMPLETED"
- 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED)
+ warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③)
- 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지)
5. 포트폴리오 동기화 트리거
[2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영
분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다:
① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데
같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는
잔량 소멸까지 폴링을 계속한다.
② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED
(/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가
당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고
status 는 COMPLETED 를 유지한다.
③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부·
만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다),
`_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락·
TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니
warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다.
"""
await asyncio.sleep(delay) # KIS 서버 체결 처리 대기
if not order_id:
# order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행
await _delayed_sync_portfolio(0)
return
try:
# KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*)
# [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응
filled_qty = 0
filled_price = 0.0
remaining_qty = None
exec_result = {}
max_polls = 5
poll_interval = 3
for poll in range(max_polls):
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
filled_qty = int(exec_result.get("filled_qty", 0) or 0)
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
remaining_qty = exec_result.get("remaining_qty")
if filled_qty >= ordered_quantity:
break # 전량 체결
if filled_qty > 0 and remaining_qty == 0:
break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음
if poll < max_polls - 1:
logger.debug(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} "
f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도"
)
await asyncio.sleep(poll_interval)
if filled_qty == 0:
logger.warning(
f"[VERIFY_EXEC] {stock_code} {order_type} "
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id} "
f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})"
)
# [M-4 3회차] 주문행이 확인됐고 잔량 0(취소·거부·만료)이면 매수는 죽었다 →
# 사이클 매수 누적에서 제외. 잔량이 살아 있거나 모르면 유지(보수적).
if order_type == "BUY" and exec_result.get("found") and (
remaining_qty == 0 or exec_result.get("cancelled")
):
_forget_cycle_buy_v2(stock_code, f"미체결·취소 확정 (order_id={order_id})")
# [L-4 4회차] 체결 0 + 잔량 소멸 = 매수 불성립 → CANCELLED. COMPLETED 로 남으면
# 일일 스윙 슬롯·already_ordered_today·Kelly 통계가 허수 매수로 오염된다.
await _mark_trade_cancelled_v2(order_id, db_trade_id, filled_qty=0)
# [H-1 4회차] 국내 SELL 이 폴링 종료 시 잔량이 KIS 에 살아 있으면(지정가 미체결) 취소한다.
# 남겨 두면 수량이 잠겨 swing/exit_service 의 시장가 손절이 종일 '주문가능수량 부족'으로
# 거부된다. 취소 성공 시 체결분만 기록(0 이면 CANCELLED), 실패 시 error.
if (
cancel_unfilled_limit and order_type == "SELL" and is_domestic
and filled_qty < ordered_quantity
and isinstance(remaining_qty, (int, float)) and remaining_qty > 0
):
_cancelled = await _cancel_unfilled_order_v2(
order_id, stock_code, int(remaining_qty), org_no=order_org_no,
)
if _cancelled:
if filled_qty <= 0:
await _mark_trade_cancelled_v2(order_id, db_trade_id, filled_qty=0)
await _rollback_active_trade_v2(stock_code, rollback_active_trade, filled_qty=0)
await _delayed_sync_portfolio(0)
return
await _rollback_active_trade_v2(stock_code, rollback_active_trade, filled_qty=filled_qty)
remaining_qty = 0 # 아래 '부분 체결 + 잔량 소멸' 경로로 체결분만 기록
if filled_qty > ordered_quantity:
# 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다
logger.warning(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 "
f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})"
)
filled_qty = ordered_quantity
# ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
slippage_pct = 0.0
if filled_qty > 0 and order_price > 0 and filled_price > 0:
slippage_pct = ((filled_price - order_price) / order_price) * 100
logger.info(
f"[SLIPPAGE] {stock_code} {order_type} "
f"주문가={order_price:.2f} 체결가={filled_price:.2f} "
f"슬리피지={slippage_pct:+.3f}%"
)
partial = 0 < filled_qty < ordered_quantity
remainder_dead = partial and remaining_qty == 0
if partial and not remainder_dead:
# 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문.
# 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실).
logger.warning(
f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, "
f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 "
f"— 재주문·원장 갱신 안 함 (order_id={order_id})"
)
# ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ───
if filled_qty > 0 and (not partial or remainder_dead):
update_fields = {
"executed_quantity": filled_qty,
"executed_price": Decimal(str(filled_price)),
# PARTIAL 금지 — 위 docstring ② (가드·집계 누락)
"status": "COMPLETED",
"executed_at": _get_kst_now(),
"slippage": Decimal(str(round(slippage_pct, 4))),
}
if db_trade_id:
# 기존 경로: trade_id로 조회 (레거시 호출 호환)
from app.services.db_worker import enqueue_update_trade_history
await enqueue_update_trade_history(db_trade_id, update_fields)
else:
# 신규 경로: order_id로 조회 (db_worker 큐 경유 INSERT 시 trade_id 미확보)
from app.services.db_worker import enqueue_update_trade_history_by_order_id
await enqueue_update_trade_history_by_order_id(order_id, update_fields)
# ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ──
if remainder_dead:
remain = ordered_quantity - filled_qty
logger.warning(
f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} "
f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) "
f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} "
f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요"
)
await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화
except Exception as e:
logger.warning(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — "
f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)"
)
await _delayed_sync_portfolio(0) # 체결 확인 실패해도 포트폴리오는 동기화
# ── 부분 체결 후 미체결 수량 재주문 ──────────────────────────────────────────
async def _reorder_unfilled(
stock_code: str,
stock_name: str,
order_type: str,
quantity: int,
price: float,
is_domestic: bool,
exchange_code: str = "NASD",
) -> None:
"""부분 체결 잔여 수량 재주문 (1회 한정)."""
try:
result = await _place_order_with_retry(
is_domestic=is_domestic,
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
exchange_code=exchange_code,
max_retries=2,
)
if result.get("rt_cd") == "0":
logger.info(f"[REORDER] {stock_code} {order_type} 미체결 {quantity}주 재주문 성공")
else:
logger.warning(f"[REORDER] {stock_code} {order_type} 미체결 재주문 실패: {result.get('msg1')}")
except Exception as e:
logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}")
def _existing_position_qty_v2(holding, stock_code: str) -> int:
"""해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용).
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5).
§5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다
(보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로
`max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다.
"""
_row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
except Exception as _e:
logger.warning(
f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}"
)
_dt_qty = 0
return max(_row_qty, _dt_qty)
def _apply_position_cap_room_v2(
invest_amount: float,
holding,
stock_code: str,
stock_name: str,
current_price: float,
total_assets: float,
position_cap_ratio: float,
) -> tuple[float, str | None]:
"""종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None).
매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액).
잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면
'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패)
한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다.
리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10).
신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다.
"""
_existing_qty = _existing_position_qty_v2(holding, stock_code)
if _existing_qty <= 0:
return invest_amount, None
_existing_value = _existing_qty * float(current_price)
if total_assets <= 0:
logger.warning(
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 "
f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 "
f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단"
)
return 0.0, "position_cap_unverifiable"
_room = remaining_position_room(total_assets, position_cap_ratio, _existing_value)
_min_buy = effective_min_buy_amount(
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
total_assets=total_assets,
position_cap_ratio=position_cap_ratio,
factor=0.9,
)
if _room < _min_buy:
logger.info(
f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 "
f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 "
f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), "
f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀"
)
return 0.0, "position_cap_reached"
if invest_amount > _room:
logger.info(
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — "
f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})"
)
return _room, None
return invest_amount, None
async def _execute_buy(
stock: dict,
db: AsyncSession,
holding,
current_price: float,
signals: list,
score_val: float,
is_domestic: bool,
exchange_code: str,
stock_code: str,
stock_name: str,
) -> dict:
"""BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → 체결 확인."""
# ── 0. 장 시작 30분 변동성 회피 (09:00~09:30 진입 차단) ──────────────────
if await check_opening_range_volatility():
logger.debug(f"[BUY_SKIP] {stock_code} — 장 시작 30분 변동성 회피 (09:00~09:30)")
return {"action": "SKIP", "stock": stock_name, "reason": "opening_range_volatility"}
# ── 0.5. 실적 필터 (Phase 2 — 기본 OFF) ──────────────────────────────────
# FUNDAMENTAL_FILTER_ENABLED=True일 때만 동작. 적자(EPS<0)·하락전망(컨센서스
# 목표가 여력 부족) 종목 진입 차단. 데이터원=네이버. **point-in-time 백테스트
# 검증(OpenDART) 전엔 켜지 말 것.** 실패 시 차단 안 함(안전).
if settings.FUNDAMENTAL_FILTER_ENABLED and is_domestic:
try:
from app.services.naver_finance import get_stock_fundamentals
_ff = await get_stock_fundamentals(stock_code)
if _ff:
if settings.FUNDAMENTAL_BLOCK_NEGATIVE_EPS and _ff.get("eps"):
try:
_eps_v = float(str(_ff["eps"]).replace(",", "").replace("원", ""))
if _eps_v < 0:
return {"action": "HOLD", "stock": stock_name,
"reason": "fundamental_negative_eps(적자)"}
except Exception as _swallowed:
warn_throttled(logger, "trade_executor:813", "[FUND_FILTER] EPS 파싱 실패: %s", _swallowed)
_tp = _ff.get("target_price")
if _tp and current_price:
try:
_upside = (float(str(_tp).replace(",", "")) / current_price - 1) * 100
if _upside < settings.FUNDAMENTAL_MIN_UPSIDE_PCT:
return {"action": "HOLD", "stock": stock_name,
"reason": f"fundamental_low_upside(목표가 여력 {_upside:+.0f}%)"}
except Exception as _swallowed:
warn_throttled(logger, "trade_executor:822", "[FUND_FILTER] 목표가 파싱 실패: %s", _swallowed)
except Exception as _ff_err:
warn_throttled(logger, "trade_executor:825", f"[FUND_FILTER] {stock_code} 실적필터 스킵: {_ff_err}")
# ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ───────────────────────────────
surge_check = await check_surge_prevention(stock_code, is_domestic, exchange_code, current_price)
if surge_check.get("skip"):
return {"action": "HOLD", "stock": stock_name, "reason": surge_check.get("reason", "chase_buy_prevention")}
# ── 2. 트렌드 필터 (MA + EMA 병행) ───────────────────────────────────────
trend_check = await check_trend_filter(stock_code, stock_name, is_domestic, exchange_code, current_price, stock)
if trend_check.get("skip"):
return {"action": "HOLD", "stock": stock_name, "reason": trend_check.get("reason", "trend_filter")}
# ── 3. 현금·자산 조회 (30초 캐시 → portfolio_cache → KIS API 직접) ─────
# [H-2 2026-10-02] _v2: 매수 차감으로 0 이 된 캐시는 신뢰(재조회 소스가 매수 전 값).
cached = _get_cached_account_v2()
# [FIX] 현금이 0인 캐시는 신뢰하지 않는다. 기존엔 available_cash=0이 캐시되면
# 그대로 재사용해 수량이 0으로 계산되어 매수 주문이 영영 안 나갔다(무한루프).
if cached:
available_cash, total_assets = cached
else:
available_cash, total_assets = 0.0, 0.0
# 1차: portfolio_cache 확인
try:
from app.api.endpoints.portfolio import portfolio_cache
summary_copy = portfolio_cache.get_summary_copy()
if summary_copy:
available_cash = float(summary_copy.get("available_cash", 0))
total_assets = float(summary_copy.get("total_assets", 0))
except Exception as _swallowed:
warn_throttled(logger, "trade_executor:852", "[BUY] 포트폴리오 캐시 현금/자산 조회 실패(KIS 직접 조회로 진행): %s", _swallowed)
# 2차: 현금이 0이면 KIS API로 직접 조회.
# [FIX] 기존 조건은 (cash<=0 AND assets<=0) 이라, 평가자산은 있는데 캐시 현금만
# 0인 경우(portfolio_cache 요약이 available_cash=0을 준 케이스) KIS 재조회를
# 건너뛰어 현금 0이 고착됐다. 현금이 0이면 자산과 무관하게 실제 예수금을 읽는다.
if available_cash <= 0:
try:
balance = await korea_investment_api.get_balance()
# NOTE: output2가 빈 리스트([])일 때 balance.get()의 default([{}])는 적용되지 않으므로
# 아래 `if output2:` 가드가 빈 리스트 케이스를 걸러낸다 (IndexError 방지 + 기존
# available_cash/total_assets 캐시값을 의도적으로 덮어쓰지 않고 보존).
output2 = balance.get("output2", [{}])
if output2:
b = output2[0] if isinstance(output2, list) else output2
# [M-3 3회차] 현금 정의 단일화 — buy_guards 와 같은 우선순위
# (ord_psbl_cash → prvs_rcdl_excc_amt → … → dnca_tot_amt).
# dnca_tot_amt(D+2 예수금)는 당일 매수가 많으면 음수/과소라 최후순위.
from app.services.buy_guards import resolve_available_cash_v2
available_cash = await resolve_available_cash_v2(
korea_investment_api, b, stock_code
)
total_assets = float(b.get("tot_evlu_amt", 0))
logger.info(f"[CASH_INIT] KIS API 직접 조회: cash={available_cash:,.0f} assets={total_assets:,.0f}")
except Exception as e:
logger.warning(f"[CASH_INIT] KIS 잔고 조회 실패: {e}")
# [FIX] 실제 현금(>0)일 때만 캐시한다. 0 현금을 캐시하면 위 무한루프가 재발한다.
if available_cash > 0:
_set_account_cache(available_cash, total_assets)
# [2026-09-23] TradeHistory.created_at 은 KST 저장(now_kst)이므로
# daily_cutoff도 UTC 기준으로 계산해야 9시간 오차가 없음.
daily_cutoff = get_daily_cutoff_utc() # 오늘 00:00:00 UTC
# ── 4. PENDING 포함 오늘 중복 주문 차단 ──────────────────────────────────
# 오늘 이미 BUY 주문이 있으면(COMPLETED 또는 PENDING 상태) 재진입 방지
pending_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PENDING"]),
TradeHistory.created_at >= daily_cutoff,
)
)
if pending_check.scalars().first():
return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"}
# ── 5. 일 매수 횟수 제한 (settings.MAX_DAILY_BUYS_PER_STOCK) ─────────────
# 종목당 당일 최대 매수 횟수를 초과하면 추가 매수 차단
daily_buy_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
TradeHistory.created_at >= daily_cutoff,
)
)
if len(daily_buy_check.scalars().all()) >= settings.MAX_DAILY_BUYS_PER_STOCK:
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
# ── 6. 쿨다운 체크 (ATR 기반 동적) ──────────────────────────────────────
cooldown_min = calculate_cooldown_minutes(stock, current_price)
cooldown_check = await db.execute(
select(TradeHistory)
.where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
TradeHistory.created_at >= daily_cutoff,
)
.order_by(desc(TradeHistory.created_at)) # 가장 최근 매수부터
)
recent_buys = cooldown_check.scalars().all()
# 마지막 매수 이후 쿨다운 시간이 지나지 않았으면 HOLD
# created_at·현재시각 모두 KST naive 기준 (예전에는 UTC now 와 KST created_at 을
# 비교해 차이가 음수가 되어 쿨다운이 항상 발동했다)
if recent_buys and (now_kst() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60:
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# ── 6.5. 상관관계 필터 (CLAUDE.md 섹션 5.4) ──────────────────────────────────
# 섹터 집중도 + 포트폴리오 상관계수 필터
# [M-1 2026-10-02] 조회·필터 실행만 try 안, 판정은 밖(§4). 기존엔 예외 시
# current_holdings=[] 로 통과해 섹터·상관 필터뿐 아니라 아래 레버리지 20% 집계와
# 전략 배분(_strategy_total_alloc)까지 0 으로 계산됐다 → 실패 시 신규 진입 차단.
# [M-3] 같은 사이클에 방금 산(아직 Portfolio 미반영) 종목을 의사 행으로 합산한다.
current_holdings = []
_corr_error: str | None = None
is_valid, filter_reason = False, None
try:
# 현재 포트폴리오 보유 종목 조회
portfolio_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
current_holdings = _merge_recent_buys_into_holdings_v2(
list(portfolio_result.scalars().all())
)
# 상관관계 필터 검증
is_valid, filter_reason = await validate_correlation_filter(
stock_code=stock_code,
portfolio_holdings=current_holdings,
max_per_sector=settings.MAX_STOCKS_PER_SECTOR if hasattr(settings, 'MAX_STOCKS_PER_SECTOR') else 3,
correlation_threshold=settings.CORRELATION_THRESHOLD if hasattr(settings, 'CORRELATION_THRESHOLD') else 0.7,
lookback_days=60
)
except Exception as e:
_corr_error = f"{type(e).__name__}: {e}"
if _corr_error is not None:
logger.warning(
f"[CORRELATION_FILTER_ERROR] {stock_code}: 보유/섹터/상관 판정 불가 — "
f"신규 진입 차단(fail-closed): {_corr_error}"
)
return {"action": "SKIP", "stock": stock_name, "reason": "correlation_filter_unavailable"}
if not is_valid:
logger.info(f"[CORRELATION_FILTER] {stock_code}/{stock_name}: {filter_reason}")
return {"action": "HOLD", "stock": stock_name, "reason": filter_reason}
logger.debug(f"[CORRELATION_FILTER] {stock_code}/{stock_name}: {filter_reason}")
# ── 6.6. 레버리지/인버스 ETF 20% 한도 — [M-2] 사이징 이후(7.7)로 이동 ─────────
# 신규 주문 금액을 포함해 판정해야 하므로 invest_amount 확정 뒤에 검사한다.
_leverage_keywords = get_leverage_check_keywords()
# ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ────────────────────────────
# 우선순위: Half-Kelly → calculate_position_size_v2 → available_cash×4%
regime_str = stock.get("_regime_str", "bear")
# CLAUDE.md 6.1: SIDEWAYS에서는 진입 불가 — 방어코드
if regime_str == "sideways":
return {"action": "SKIP", "reason": "sideways_regime", "stock": stock_name}
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
# [이슈7 수정] current_strategy_allocation은 단일 종목 보유액이 아니라
# 해당 전략(mean_reversion) 전체 보유액을 전달해야 포지션 한도가 정확히 계산됨.
# current_holdings는 섹션 6.5에서 이미 조회된 Portfolio 리스트를 재활용.
strategy_tag = stock.get("strategy_type", "swing")
_strategy_total_alloc = 0.0
for _h in current_holdings:
_h_strategy = getattr(_h, "strategy_type", None) or "swing"
_h_qty = float(getattr(_h, "quantity", 0) or 0)
_h_price = float(getattr(_h, "current_price", 0) or getattr(_h, "average_price", 0) or 0)
if _h_strategy == strategy_tag:
_strategy_total_alloc += _h_qty * _h_price
# ── 7.0. 적응형 전략 파라미터 적용 ─────────────────────────────────────
# position_size 상한을 적응형 모드에 따라 조정 (Crisis=1.5%, Defensive=4% 등)
#
# [HIGH-2 수정] settings.MAX_POSITION_SIZE(4%)는 CLAUDE.md §5.1/§8.2가 규정한
# "종목당 절대 상한(hard ceiling)"이다. adaptive_strategy._MODE_PARAMS 는 문서·백테스트
# 기준값이므로 그 숫자 자체는 건드리지 않고(Normal 6% / Aggressive 7% 유지),
# 소비 지점인 여기서 min()으로 클램프해 절대 상한을 넘지 못하게 한다.
# → 결과적으로 Normal/Aggressive의 실효 종목당 비중은 4%로 묶이고,
# Defensive(4%)/Crisis(1.5%)처럼 4% 이하인 모드만 적응형 값이 실제로 작동한다.
# [§4 2026-10-02] 조회만 try 안. 기존엔 클램프·Crisis 차단이 try 안에 있어
# 예외 시 둘 다 조용히 건너뛰었다. 조회 실패 = Crisis 여부 불명 → 진입 차단.
_adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% = 절대 상한
_adp = None
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
except Exception as e:
logger.warning(
f"[ADAPTIVE] {stock_code}: 적응형 파라미터 조회 실패 — Crisis 여부 불명으로 "
f"신규 진입 차단(fail-closed): {type(e).__name__}: {e}"
)
return {"action": "SKIP", "reason": "adaptive_params_unavailable", "stock": stock_name}
if _adp is None:
logger.warning(f"[ADAPTIVE] {stock_code}: 적응형 파라미터 None — 신규 진입 차단(fail-closed)")
return {"action": "SKIP", "reason": "adaptive_params_unavailable", "stock": stock_name}
if not getattr(_adp, "fallback_used", False):
# Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.4)
if getattr(_adp, "trading_mode", None) == "crisis" and (getattr(_adp, "max_daily_trades", 0) or 0) <= 0:
return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name}
try:
_adaptive_position_cap = float(_adp.position_size)
except (TypeError, ValueError, AttributeError) as e:
logger.warning(
f"[ADAPTIVE] {stock_code}: position_size 해석 실패 — MAX_POSITION_SIZE 사용: {e}"
)
_adaptive_position_cap = settings.MAX_POSITION_SIZE
# 절대 상한 클램프 — 무조건 적용 (CLAUDE.md §5.1 hard ceiling)
_adaptive_position_cap = max(0.0, min(_adaptive_position_cap, settings.MAX_POSITION_SIZE))
invest_amount = await _calculate_kelly_position(
db, stock_code, total_assets, available_cash,
max_per_position=_adaptive_position_cap,
)
if invest_amount <= 0:
# Kelly 데이터 부족(5거래 미만) → 전략 v2 fallback
invest_amount = calculate_position_size_v2(
total_assets=total_assets,
available_cash=available_cash,
current_strategy_allocation=_strategy_total_alloc,
regime=regime,
rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
max_per_position=_adaptive_position_cap,
)
if invest_amount <= 0:
# v2도 0 반환 시(현금 부족 등) → 적응형 position_size 이내
invest_amount = min(available_cash, total_assets * _adaptive_position_cap)
# [#9] 글로벌 자본 가드 — 현금이 전체 자본의 (1-MAX_TOTAL_CAPITAL_USAGE)% 미만이면 진입 차단
min_cash_reserve = total_assets * (1 - settings.MAX_TOTAL_CAPITAL_USAGE)
if available_cash - invest_amount < min_cash_reserve:
invest_amount = max(0, available_cash - min_cash_reserve)
if invest_amount <= 0:
return {"action": "SKIP", "reason": "global_capital_guard", "stock": stock_name}
# [스윙:DT 자본 배분 2026-09-22] 스윙 합산 투자액이 총자산 × SWING_MAX_CAPITAL_RATIO 를
# 넘지 않도록 상한. 스윙 투자액 = 주식 평가액 − 데이트레이딩 포지션 투자액.
# (스윙이 자본 91%를 점유해 DT 예산이 남지 않던 문제 → 50:50)
_swing_cap_ratio = float(getattr(settings, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0)
if 0 < _swing_cap_ratio < 1 and total_assets > 0:
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_amount = sum(
float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0)
for p in _dts.positions.values()
)
except Exception:
_dt_amount = 0.0
_swing_invested = max(0.0, total_assets - available_cash - _dt_amount)
_swing_room = total_assets * _swing_cap_ratio - _swing_invested
if invest_amount > _swing_room:
invest_amount = max(0.0, _swing_room)
if invest_amount <= 0:
logger.info(
f"[TRADE] {stock_name}: 스윙 자본 상한 도달 "
f"({_swing_invested/total_assets:.1%} ≥ {_swing_cap_ratio:.0%}) — 신규 매수 차단"
)
return {"action": "SKIP", "reason": "swing_capital_cap", "stock": stock_name}
# ── 7.5. 패턴 메모리 기반 포지션 조정 ──────────────────────────────────
# 종목별 과거 행동 패턴(지지/저항선, RSI 반등, 승률 등)을 기반으로
# 투자금액을 -30% ~ +30% 범위에서 동적 조정.
# CLAUDE.md 5.1 절대 상한(4%)은 조정 후 재적용.
try:
from app.services.stock_pattern_memory import get_pattern_adjusted_invest_amount
invest_amount = await get_pattern_adjusted_invest_amount(
stock_code=stock_code,
base_invest_amount=invest_amount,
rsi=stock.get("rsi"),
current_price=current_price,
current_volume=float(stock.get("volume", 0) or 0),
)
except Exception as _pm_err:
# [HIGH-1 수정] 조용한 실패 방지 — debug → warning.
logger.warning(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (기본 투자금액 유지): {_pm_err}")
# [HIGH-1 수정] CLAUDE.md §5.1 종목당 4% 절대 상한 재적용.
# 과거에는 이 클램프가 위 try 블록 안에 있어, get_pattern_adjusted_invest_amount()가
# 예외를 던지면 클램프를 건너뛰고 Kelly/v2가 _adaptive_position_cap(적응형 6~7%)로
# 산출한 금액이 그대로 통과했다. 예외 발생 여부와 무관하게 항상 적용되도록 try 밖으로 이동.
# total_assets=0(잔고 조회 실패 등)이면 비율 상한을 계산할 수 없으므로 현금 상한만 적용
# — 0으로 클램프해 전 종목이 quantity_zero로 조용히 탈락하는 것을 방지.
# [§8.5-2 통합 2026-09-27] 클램프 수식만 sizing_core 로 위임 — 클램프 **순서**
# (글로벌 자본 가드 → 스윙 자본 캡 → 패턴 메모리 → 절대 상한)와 total_assets>0
# 분기는 최근 확정된 동작이므로 그대로 유지한다.
if total_assets > 0:
invest_amount = clamp_by_ratios(
invest_amount, total_assets, [settings.MAX_POSITION_SIZE], available_cash,
)
else:
invest_amount = min(invest_amount, available_cash)
# ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ─────────────────────────
# [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면
# 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을
# 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10).
_pos_cap_result = _apply_position_cap_room_v2(
invest_amount=invest_amount,
holding=holding,
stock_code=stock_code,
stock_name=stock_name,
current_price=current_price,
total_assets=total_assets,
position_cap_ratio=_adaptive_position_cap,
)
if _pos_cap_result[1] is not None:
return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name}
invest_amount = _pos_cap_result[0]
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
# [M-4 2026-10-02] `float(x or 1.0)` 는 0.0(진입 차단)을 1.0 으로 바꿨다.
_position_multiplier = _parse_scale_v2(
stock.get("_position_multiplier"), "_position_multiplier", stock_code
)
_news_position_scale = _parse_scale_v2(
stock.get("_news_position_scale"), "_news_position_scale", stock_code
)
if _position_multiplier != 1.0 or _news_position_scale != 1.0:
invest_amount = invest_amount * _position_multiplier * _news_position_scale
logger.debug(
f"[POSITION_SCALE] {stock_code}: position_multiplier={_position_multiplier:.2f} "
f"news_scale={_news_position_scale:.2f} → invest_amount={invest_amount:,.0f}"
)
# ── 7.7. 레버리지/인버스 ETF 20% 한도 (CLAUDE.md §5.1) ─────────────────
# [M-2 2026-10-02] 신규 주문분 포함: (보유 레버리지 평가액 + invest_amount) / 총자산.
# 기존엔 보유분만 봐서 19% 보유 상태에서 4% 를 더 사 23% 가 될 수 있었다.
# [M-1] 판정은 try 밖. 총자산·보유 평가를 알 수 없으면 한도 검증 불가 → 차단.
if is_leverage_etf(stock_name, _leverage_keywords) and invest_amount > 0:
_lev_total = _leverage_exposure_v2(current_holdings, _leverage_keywords)
if _lev_total is None or total_assets <= 0:
logger.warning(
f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: 레버리지 한도 "
f"{settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 검증 불가 "
f"(total_assets={total_assets:,.0f}) — 신규 진입 차단(fail-closed)"
)
return {"action": "SKIP", "stock": stock_name, "reason": "leverage_limit_unverifiable"}
_lev_ratio_after = (_lev_total + invest_amount) / total_assets
if _lev_ratio_after > settings.LEVERAGE_ETF_MAX_RATIO:
logger.warning(
f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: 매수 후 레버리지 ETF 비중 "
f"{_lev_ratio_after*100:.1f}% (보유 {_lev_total:,.0f} + 신규 {invest_amount:,.0f}) "
f"> {settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 한도"
)
return {"action": "HOLD", "stock": stock_name, "reason": "leverage_etf_limit_exceeded"}
# 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지)
# [§8.5-2 통합 2026-09-27] sizing_core.amount_to_quantity 위임 — 동일한
# int(Decimal(str(amount))/Decimal(str(price))) 경로. max(1, ...) 없음(예산 0 → 0주).
quantity = amount_to_quantity(invest_amount, current_price)
logger.info(
f"[BUY_CALC] {stock_code}/{stock_name}: cash={available_cash:,.0f} assets={total_assets:,.0f} "
f"invest={invest_amount:,.0f} qty={quantity} price={current_price:,.0f}"
)
if quantity <= 0:
# [관측성] 수량 0 원인을 구분: 배율(플레이북 FOMC·국면·뉴스)로 투자금이
# 0이 됐는지, 아니면 현금 부족인지. "매수 실행 후 조용히 스킵"의 정체를 로그로.
if _position_multiplier <= 0 or _news_position_scale <= 0:
_qz_reason = "entry_blocked_multiplier_0(플레이북/국면/뉴스로 진입차단)"
else:
_qz_reason = "quantity_zero(현금/가격 대비 1주 미만)"
logger.info(f"[BUY_SKIP] {stock_code}/{stock_name}: {_qz_reason} (invest={invest_amount:,.0f})")
return {"action": "SKIP", "reason": _qz_reason, "stock": stock_name}
# 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 15만원)
#
# [MEDIUM-5 문서 불일치 — 의도된 설계]
# CLAUDE.md §5.1/§8.2는 최소주문금액을 "15만원 고정"으로 서술하지만,
# 실제 구현은 아래와 같이 소액 계좌에서만 자동 하향된다:
# 실효하한 = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형포지션상한 × 0.9)
# 이유: 총자산이 작으면 종목당 예산(총자산×4%) 자체가 15만원 미만이 되어
# 모든 종목이 min_amount_guard로 영구 차단된다(2026-09-23 하루 141건 차단 사례).
# 큰 계좌에서는 항상 config값(15만원)이 더 작게 선택되므로 문서와 동일하게 동작한다.
# → 동작을 문서에 맞추는 대신 문서(§5.1)를 이 동작에 맞춰 보정할 예정. 코드 변경 금지.
# 예) 총자산 300만원 × 4% × 0.9 = 108,000원이 실효 하한(15만원 아님).
# [§8.5-2 통합 2026-09-27] 계산식만 sizing_core.effective_min_buy_amount 로 위임.
# = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형cap × 0.9). 로직·계수 변경 없음.
try:
_MIN_BUY_AMOUNT = effective_min_buy_amount(
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
total_assets=total_assets,
position_cap_ratio=_adaptive_position_cap,
factor=0.9,
)
except Exception:
_MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW
_actual_amount = quantity * current_price
if _actual_amount < _MIN_BUY_AMOUNT:
logger.info(f"[SKIP] {stock_code}: 매수금액 {_actual_amount:,.0f}원 < 최소 {_MIN_BUY_AMOUNT:,.0f}원")
return {"action": "SKIP", "reason": "min_amount_guard", "stock": stock_name}
# ── 8. Redis 분산락 획득 ──────────────────────────────────────────────────
# 여러 워커/프로세스가 동시에 같은 종목을 매수하는 것을 방지
if not await _acquire_order_lock(stock_code, "BUY"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
# ── 9. 주문 실행 (종목별 세마포어 + 재시도) ───────────────────────────────
# 세마포어: 단일 프로세스 내 동일 종목 동시 주문 방지
# 재시도: exponential backoff (1→2→4초, 최대 3회)
try:
order_price = _adjust_order_price(current_price, "BUY", is_domestic) # +0.5% 체결률 향상
async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry(
is_domestic=is_domestic,
stock_code=stock_code,
order_type="BUY",
quantity=quantity,
price=order_price,
exchange_code=exchange_code,
)
total_amount = Decimal(str(order_price)) * quantity
# ── 매수 스토리 구성: 기술 + 뉴스 + 실적 + 전망 (Phase 1: 기록 전용) ──────
# 주문은 이미 위에서 접수됐으므로 여기서의 펀더멘털 조회는 주문을 지연·차단하지
# 않는다(실패/타임아웃 시 기술 스토리로 폴백). 매매 판단엔 미사용.
_story = [f"기술: {', '.join(signals[:3])} (점수 {score_val})"]
try:
_ns = stock.get("_news_position_scale")
if _ns is not None and float(_ns) < 1.0:
_story.append(f"뉴스: 악재 감지 → 비중 {float(_ns):.0%}")
except Exception as _swallowed:
warn_throttled(logger, "trade_executor:1210", "[STORY] 뉴스 배율 스토리 생성 실패: %s", _swallowed)
if is_domestic:
try:
from app.services.naver_finance import get_stock_fundamentals
_fd = await get_stock_fundamentals(stock_code)
if _fd:
_val = []
if _fd.get("per"):
_val.append(f"PER {_fd['per']}")
if _fd.get("eps"):
_val.append(f"EPS {_fd['eps']}")
if _val:
_story.append("실적: " + ", ".join(_val))
_tp = _fd.get("target_price")
if _tp:
try:
_tpv = float(str(_tp).replace(",", ""))
_up = (_tpv / current_price - 1) * 100 if current_price else 0.0
_outlook = f"전망: 목표가 {_tp}({_up:+.0f}%)"
if _fd.get("recomm_mean"):
_outlook += f", 투자의견 {_fd['recomm_mean']}/5"
_story.append(_outlook)
except Exception as _swallowed:
warn_throttled(logger, "trade_executor:1233", "[STORY] 목표가 스토리 생성 실패: %s", _swallowed)
except Exception as _story_err:
warn_throttled(logger, "trade_executor:1236", f"[STORY] {stock_code} 펀더멘털 스킵: {_story_err}")
entry_reason = " | ".join(_story)
success = order_result.get("rt_cd") == "0" # KIS 성공 코드
# [H-3 5회차] 전송 후 결과 불명 — 체결됐을 수 있다. 거부 쿨다운 대상 아님.
_outcome_unknown = (not success) and _is_order_outcome_unknown_v2(order_result)
if not success and not _outcome_unknown and not _is_retryable_order_failure(order_result):
# [M-1 3회차] 업무 거부(rt_cd "1" 등) — 같은 종목 반복 주문 방지용 단기 쿨다운
_note_order_reject_v2(stock_code, order_result)
# [H-1 2026-10-02] rt_cd≠"0" 은 KIS 거부/실패다. 기존엔 "1" 을 PENDING 으로
# 기록해 일일 스윙 한도·already_ordered_today·note_system_buy 가 거부 주문에
# 소진됐다. 체결 확인 전 상태는 _verify_and_sync_execution 이 갱신한다.
# [H-3 5회차] 단, 결과 불명은 PENDING('접수·체결 미확인') — 일일 스윙 한도·
# already_ordered_today·당일 매도 판정·유입잔고 강등 근거가 모두 PENDING 을
# 접수된 주문으로 센다. FAILED 로 두면 같은 사이클/다음 사이클이 재매수해 이중 체결.
status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED")
# [2026-09-30] 유입 잔고 강등 레이스 방어 — '시스템 매수' 를 **DB 반영 전에**
# 인메모리로 즉시 등록한다. 아래 enqueue 들은 db_worker 큐를 거쳐 비동기로
# INSERT 되는데, `_verify_and_sync_execution()` 이 곧바로 `sync_portfolio()`
# 를 돌리므로 그 사이에 sync 가 "근거 없음 → external 강등" 판정을 내리면
# 방금 산 포지션의 손절·익절이 통째로 멈춘다.
# 주문 거부(FAILED)는 주식이 생기지 않으므로 등록하지 않는다 — 등록하면
# 기존 유입 잔고가 '매수 실패' 만으로 격리를 면한다.
if success:
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
# [H-2] 같은 사이클의 현금 95%·스윙 70% 가드가 이 매수를 반영하도록 즉시 차감.
# [Low 3회차] 계좌 캐시는 KRW(국내 예수금)다 — 해외(USD) 매수 금액을 빼면
# 단위가 섞여 국내 가용현금이 과소평가된다 → 국내 매수만 차감.
if is_domestic:
_deduct_account_cache_v2(
float(order_price) * quantity, available_cash, total_assets
)
# [M-3] 같은 사이클의 보유수·섹터·레버리지 판정에 합산.
_note_cycle_buy_v2(stock_code, stock_name, quantity, float(order_price))
# [M-3 4회차] 일일 스윙 한도 집계용 전략 태그 (TradeHistory.strategy_type 과 같은 값)
if stock_code in _recent_cycle_buys:
_recent_cycle_buys[stock_code]["strategy_type"] = stock.get("strategy_type", "swing")
elif _outcome_unknown:
# [H-3 5회차] 체결됐을 수 있으므로 '산 것'으로 보수 집계(보유수·일일 한도·강등 근거).
# 실제 여부는 잔고 동기화가 확정한다. 현금 캐시는 차감하지 않는다(과소 추정 회피 불필요).
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
_note_cycle_buy_v2(stock_code, stock_name, quantity, float(order_price))
if stock_code in _recent_cycle_buys:
_recent_cycle_buys[stock_code]["strategy_type"] = stock.get("strategy_type", "swing")
asyncio.create_task(_delayed_sync_portfolio(3))
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
kis_order_id = build_kis_order_id(order_result)
from app.services.db_worker import enqueue_trade_history
await enqueue_trade_history({
"rule_id": None, # JSON config rule은 trading_rules 테이블에 없으므로 FK 위반 방지
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "BUY",
"order_price": Decimal(str(order_price)),
"order_quantity": quantity,
"executed_price": Decimal(str(order_price)) if success else None,
"executed_quantity": quantity if success else 0,
"total_amount": total_amount,
"status": status,
"order_id": kis_order_id,
"message": f"자동매수: {'추가매수' if holding else '신규'} | {' | '.join(signals[:2])}",
"entry_reason": entry_reason,
"signals": signals,
"strategy_type": stock.get("strategy_type", "swing"),
"currency_code": get_market_info(stock_code)["currency"],
"exchange_code": exchange_code,
"created_at": _get_kst_now(),
"executed_at": _get_kst_now() if success else None,
})
if success:
# [C2] ActiveTrade 생성 — 분할매도/트레일링 스탑 추적용
try:
from app.services.db_worker import enqueue_active_trade_upsert
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"entry_price": order_price,
# 듀얼 전략(MOMENTUM_BREAKOUT/PULLBACK_BUY)은 strategy_name 으로 전달돼
# 청산 분기를 결정한다. 없으면 strategy_type 폴백.
"strategy_name": stock.get("strategy_name") or stock.get("strategy_type", "swing"),
"stop_loss": stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)),
"take_profit": stock.get("take_profit", 5.0),
"entry_reason": entry_reason,
"highest_since_entry": order_price,
})
except Exception as _at_err:
logger.warning(f"[BUY] ActiveTrade 생성 실패 {stock_code}: {_at_err}")
# 인메모리 포트폴리오 캐시 즉시 반영 (UI 실시간 업데이트)
try:
from app.api.endpoints.portfolio import portfolio_cache
await portfolio_cache.apply_buy(stock_code, stock_name, quantity, order_price)
except Exception as _swallowed:
logger.warning("[BUY] portfolio_cache.apply_buy 실패: %s", _swallowed)
# WebSocket → 프론트엔드 즉시 갱신 트리거
try:
from app.services.websocket_manager import websocket_manager
await websocket_manager.broadcast_json({
"type": "trade_executed",
"data": {
"stock_code": stock_code,
"stock_name": stock_name,
"trade_type": "BUY",
"quantity": quantity,
"price": current_price,
},
})
except Exception as _swallowed:
logger.warning("[BUY] WebSocket 갱신 트리거 실패: %s", _swallowed)
# Telegram 매수 체결 알림
try:
from app.services.telegram_notifier import telegram_notifier
asyncio.create_task(telegram_notifier.notify_trade_executed(
order_type="BUY",
stock_code=stock_code,
stock_name=stock_name,
quantity=quantity,
price=current_price,
))
except Exception as _swallowed:
logger.warning("[BUY] 텔레그램 체결 알림 실패: %s", _swallowed)
# 3초 후 KIS 체결 확인 → 슬리피지 기록 + 부분체결 재주문 (백그라운드)
asyncio.create_task(
_verify_and_sync_execution(
order_id=kis_order_id,
stock_code=stock_code,
stock_name=stock_name,
ordered_quantity=quantity,
order_price=order_price,
order_type="BUY",
is_domestic=is_domestic,
exchange_code=exchange_code,
db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회
delay=3,
)
)
# [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 — rt_cd≠"0" 은 거부라
# 취소할 주문이 없다(주문번호도 없음). 접수 성공 건의 미체결 정리는
# _verify_and_sync_execution 경로가 담당한다.
return {
"action": "BUY",
"stock": stock_name,
"quantity": quantity,
"price": current_price,
"success": success,
}
except ValueError as e:
logger.error(f"[BUY_VALIDATION] {stock_code}: {e}")
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
except Exception as e:
logger.error(f"[BUY_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
finally:
await _release_order_lock(stock_code, "BUY")
def _dt_inmemory_qty_v2(stock_code: str) -> int | None:
"""DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급)."""
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
except Exception as e:
logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}")
return None
# 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙.
# external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE)
# manual : manual_buy / portfolio_v2 수동 매수
_MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual")
# 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함).
# 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계
# (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것.
# swing/position_manager.STRATEGY_TYPE("swing") 과 동일.
_SWING_OWNER_STRATEGY_TYPES = ("swing",)
def _swing_owned_clause_v2(column):
"""스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`.
NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다).
"""
return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES))
def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None:
"""추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정.
swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과
같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유.
- external / manual → 'manual_judgment_holding' (warning)
- DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유
(단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용)
- 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용
strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다.
MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다
(각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11).
"""
from app.services.db_worker import DT_STRATEGY_NAMES
_holding_type = (getattr(holding, "strategy_type", None) or "swing")
_caller_tag = stock.get("strategy_type") or "swing"
if _holding_type in _SWING_OWNER_STRATEGY_TYPES:
return None
if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES:
return "manual_judgment_holding"
if _holding_type in DT_STRATEGY_NAMES:
return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading"
return None if _caller_tag == _holding_type else "owned_by_other_strategy"
async def _execute_sell(
stock: dict,
db: AsyncSession,
holding,
current_price: float,
signals: list,
is_domestic: bool,
exchange_code: str,
stock_code: str,
stock_name: str,
sell_pct: float = 1.0, # [CLAUDE.md 5.2] 분할매도 비율 (0.5 = 50%, 1.0 = 전량)
) -> dict:
"""SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인.
[동작 흐름]
1. 현재 손익률 계산 (현재가 vs 평균매입가)
2. ATR 기반 동적 손절선·트레일링스탑 재계산
3. Redis 분산락 획득 → 중복 매도 방지
4. 30초 이내 동일 종목 SELL 기록 확인 (FAILED 제외)
5. 지정가 -0.5% 조정 후 KIS SELL 주문
6. TradeHistory DB 기록 (flush → id 확보 → commit)
7. 성공 시: 포트폴리오 캐시 즉시 제거 + 체결 확인 백그라운드 태스크
[2026-03-15 CLAUDE.md 정합성 수정]
- sell_pct 파라미터 추가: check_exit_signal의 SELL_PARTIAL(50%) 지원
- 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반)
"""
# ── 보유 행 소유권 가드 (2026-10-01) ─────────────────────────────────────
# 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다
# (external·manual 유입 잔고, strategy_type='daytrading' 행 포함).
# 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산·
# swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4).
_ownership_block = _sell_ownership_block_reason_v2(holding, stock)
if _ownership_block is not None:
_h_type = getattr(holding, "strategy_type", None)
_msg = (
f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 "
f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, "
f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')"
)
if _ownership_block == "owned_by_daytrading":
# [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면
# 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로
# info 에 묻히지 않게 warning 으로 올린다.
_dt_mem_qty = _dt_inmemory_qty_v2(stock_code)
if _dt_mem_qty is None or _dt_mem_qty <= 0:
logger.warning(
_msg + f" — DT 인메모리 포지션 "
f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 "
f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, "
f"수동 확인 필요 ({stock_name}/{stock_code})"
)
else:
logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)")
elif _ownership_block == "manual_judgment_holding":
logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상")
else:
logger.warning(_msg + " — 다른 전략 엔진 소유")
return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name}
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
# [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
# → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다.
total_qty = holding.quantity
# [M-3 2026-09-27] 변수 기준 구분:
# _row_total_qty = Portfolio 행 수량(스윙+DT 합산) — 포트폴리오 캐시/DB 행 기준
# total_qty = 이 전략의 보유분(_own_qty, DT 제외) — 주문·잔량 회계 기준
# 캐시 반영은 반드시 행 합산 기준(_row_total_qty)을 써야 DT 보유분이 사라지지 않는다
# (swing/exit_service.reflect_sell / portfolio_cache_bridge 와 동일 기준).
_row_total_qty = int(total_qty or 0)
_strategy_tag = stock.get("strategy_type", "swing")
if _strategy_tag != "daytrading":
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_pos = _dts.positions.get(stock_code)
_dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0)
except Exception:
_dt_qty = 0
if _dt_qty > 0:
_own = max(0, total_qty - _dt_qty)
if _own <= 0:
logger.info(
f"[SELL] {stock_name}: 보유 {total_qty}주 전량이 데이트레이딩 포지션 — 스윙 청산 스킵"
)
return {"action": "SKIP", "reason": "owned_by_daytrading", "stock": stock_name}
logger.info(
f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상"
)
total_qty = _own # = _own_qty (이 전략 보유분)
# ── 매도 수량 결정 (원본 수량 기준) ──────────────────────────────────────
# [M-1 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율.
# check_exit_signal(>0.50) / _check_momentum_exit(>0.75, 0.45~0.75) /
# range_trading_strategy(>0.50) 의 remaining_pct 게이트가 원본 누적
# (1.0 → 0.70 → 0.40)을 전제하므로 매도 수량도 원본 기준이어야 한다.
# 기존 코드는 수량은 '현재 잔량' 기준, new_remaining 은 '원본' 기준 차감이라
# 분할이 반복될수록 과소매도 + remaining_pct 오염이 누적됐다.
# → swing/exit_service.py 와 동일하게 원본 수량을 remaining_pct 로 역산한다.
_rp_for_qty, _rp_tracked_for_qty = _resolve_remaining_pct_v2(stock, holding)
_original_qty = total_qty
if _rp_tracked_for_qty and 0.0 < _rp_for_qty < 1.0:
# 0 나눗셈 가드 = 위 조건(0 < rp), original_qty < total_qty 가드 = max()
_original_qty = max(total_qty, int(round(total_qty / _rp_for_qty)))
elif not _rp_tracked_for_qty and sell_pct < 1.0:
# 추적 불가(ActiveTrade active 행 없음/오염) → 역산 불가.
# 상위 execute_auto_trade 가 이 경우 부분매도 신호를 폐기하므로(§8.5 항목4①)
# 정상 경로에서는 도달하지 않는다. 방어적으로 '현재 잔량 = 원본' 으로 두어
# 기존 동작(현재 잔량 기준 비율 매도)을 유지한다.
logger.warning(
f"[SELL] {stock_code}: remaining_pct 추적 불가 상태에서 부분매도 요청 "
f"(sell_pct={sell_pct*100:.0f}%) — 원본 수량 역산 불가, 현재 잔량 기준 적용"
)
if sell_pct >= 1.0:
quantity = total_qty # 전량매도 시 잔여분 없도록 (기존 동작 유지)
else:
quantity = min(max(int(_original_qty * sell_pct), 1), total_qty)
# [M-2 2026-09-27] 계산된 매도 수량이 보유 전량과 같아지면(1~2주 소량 보유 +
# sell_pct=0.5 → max(1,·) 로 전량 체결) 실질적 전량 청산이다. sell_pct<1.0
# 분기를 그대로 타면 close_active_trade 가 호출되지 않아 quantity=0 유령 active
# 행이 남고, loss_tracker.record_trade_result_safe() 도 스킵돼 §5.1 3연패 밴
# 카운트에서 이 청산이 빠진다 → 실제 체결 기준으로 전량 청산 처리한다.
_full_exit = sell_pct >= 1.0 or (total_qty > 0 and quantity >= total_qty)
if _full_exit and sell_pct < 1.0:
logger.info(
f"[SELL] {stock_code}: 부분매도 요청({sell_pct*100:.0f}%)이 보유 전량"
f"({total_qty}주)과 동일 → 전량 청산으로 처리 (ActiveTrade 종료 + 손실추적)"
)
_avg = float(holding.average_price or 0)
profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate)
# [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R
take_profit = stock.get("take_profit", 5.0)
base_stop_loss = stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT))
# ── ATR 기반 동적 손절/트레일링 스탑 조정 ────────────────────────────────
atr = float(stock.get("atr", 0) or 0)
atr_stops = calculate_atr_based_stops(
_avg, current_price, atr, stock,
base_stop_loss=base_stop_loss
)
# [2026-09-27] atr_stops["stop_loss"] 는 _execute_sell 내에서 사용되지 않아 제거
# (실제 손절 판정은 execute_auto_trade의 ATR 강제 손절/check_exit_signal에서 수행)
trailing_stop_price = atr_stops["trailing_stop_price"]
exit_reason_detail = atr_stops["exit_reason_detail"] or f"손익률 {profit_loss_rate:.2f}%"
# Redis 분산락 획득 — 여러 워커가 동시에 같은 종목을 매도하는 것을 방지
if not await _acquire_order_lock(stock_code, "SELL"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
try:
# ── 1. 중복 주문 체크 (30초 이내, FAILED 제외) ───────────────────────
# FAILED 주문은 제외하여 오류 이후 재매도 허용
recent_cutoff = _get_kst_now() - timedelta(seconds=30)
duplicate_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "PARTIAL", "PENDING"]),
TradeHistory.created_at >= recent_cutoff,
)
)
if duplicate_check.scalars().first():
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# ── 2. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
# [H-1 4회차 2026-10-02] current_price 는 추천 경로에서 StockAnalysis 배치 값(최대 30분
# 낡음)이다. 그 ×0.995 지정가는 하락 중 미체결로 남아 수량을 잠가 swing/exit_service 의
# 시장가 손절까지 거부됐다. → 국내:
# - 전량 청산·손실 청산(손절) = 시장가(price=None)
# - 부분 익절 = 주문 직전 현재가로 재산정한 지정가(조회 실패 시 시장가)
# 해외는 기존 유지(US_TRADING_ENABLED=False, 시장가 미지원 경로).
_ref_price, order_price, _order_kind = _resolve_sell_order_price_v2(
stock_code, current_price, is_domestic, _full_exit, profit_loss_rate
)
if _order_kind == "limit_refresh":
_ref_price = await _fresh_domestic_price_v2(stock_code)
if _ref_price > 0:
order_price = _adjust_order_price(_ref_price, "SELL", is_domestic)
else:
_ref_price, order_price, _order_kind = current_price, None, "market_fallback"
logger.info(
f"[SELL] {stock_code}/{stock_name}: 주문 방식={_order_kind} "
f"(가격={'시장가' if order_price is None else f'{order_price:,.2f}'}, 기준가={_ref_price:,.2f}, "
f"배치가={current_price:,.2f}, 전량={_full_exit}, 손익률={profit_loss_rate:.2f}%)"
)
async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry(
is_domestic=is_domestic,
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=order_price,
exchange_code=exchange_code,
)
# 체결 확인·슬리피지 기준가 (시장가면 직전 기준가)
_verify_ref_price = float(order_price) if order_price is not None else float(_ref_price)
total_amount = Decimal(str(current_price)) * quantity
gross_profit_loss = (current_price - float(holding.average_price or current_price)) * quantity
# [#4 수수료/세금 반영] net PnL 계산
_market_type = stock.get("market", "KOSPI") or "KOSPI"
trade_costs = calculate_trade_costs(
entry_price=_avg, exit_price=current_price, quantity=quantity,
is_domestic=is_domestic, market=_market_type,
)
profit_loss = gross_profit_loss - trade_costs["total_cost"] # net 손익
success = order_result.get("rt_cd") == "0"
# [H-1 2026-10-02] rt_cd≠"0" = 거부/실패 → FAILED (PENDING 으로 남기면 30초
# 중복매도 차단·당일 부분매도 차단이 거부 주문에 소진된다).
# [H-3 5회차] 단 전송 후 결과 불명은 PENDING — 팔렸을 수 있으므로 30초 중복매도 차단·
# 당일 매도 판정이 이 주문을 접수된 것으로 봐야 재매도(과다매도)가 나지 않는다.
_outcome_unknown = (not success) and _is_order_outcome_unknown_v2(order_result)
status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED")
if _outcome_unknown:
asyncio.create_task(_delayed_sync_portfolio(3))
# [H-1 5회차] 미체결 자동 취소 = 국내 지정가 부분 익절 + 자기 주문번호일 때만.
# 시장가(전량·손절)는 절대 취소하지 않는다. 조회로 매칭된 주문(_matched_by_lookup)은
# 자기 주문이라는 보장이 없어 취소 대상에서 뺀다(L-5).
_cancel_unfilled_limit = (
is_domestic and order_price is not None and not _full_exit
and not order_result.get("_matched_by_lookup")
)
_at_rollback: dict | None = None
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
kis_order_id = build_kis_order_id(order_result)
from app.services.db_worker import enqueue_trade_history
await enqueue_trade_history({
"rule_id": None,
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": Decimal(str(current_price)),
"order_quantity": quantity,
"executed_price": Decimal(str(current_price)) if success else None,
"executed_quantity": quantity if success else 0,
"total_amount": total_amount,
"status": status,
"order_id": kis_order_id,
"message": f"자동매도: {exit_reason_detail}",
"exit_reason": f"손익: {profit_loss:,.0f}원 (수수료/세금: {trade_costs['total_cost']:,.0f}원)",
"signals": signals,
"commission": Decimal(str(trade_costs["total_cost"])) if success else None,
"net_profit_loss": Decimal(str(profit_loss)) if success else None,
"profit_loss_rate": Decimal(str(profit_loss_rate)) if success else None,
"strategy_type": stock.get("strategy_type", "swing"),
"currency_code": get_market_info(stock_code)["currency"],
"exchange_code": exchange_code,
"created_at": _get_kst_now(),
"executed_at": _get_kst_now() if success else None,
})
if success:
# [CLAUDE.md 5.5 / 8.1#6] 연속 손실 추적 — 전량매도 시 loss_tracker 기록
# [M-2] _full_exit = sell_pct>=1.0 또는 '계산 수량 == 보유 전량'
if _full_exit:
try:
from app.services.loss_tracker import loss_tracker
is_loss = profit_loss_rate < 0
await loss_tracker.record_trade_result_safe(
stock_code=stock_code,
is_loss=is_loss,
)
await loss_tracker.save_to_db(stock_code=stock_code)
except Exception as _lt_err:
logger.warning(f"[SELL] loss_tracker 업데이트 실패 {stock_code}: {_lt_err}")
# [C2] ActiveTrade 종료/갱신
try:
# 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면
# 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다.
_at_name = stock.get("strategy_name")
# [§8.5 항목3 2026-09-27] 역추적 쿼리에 DT 제외 조건이 없어, 같은 종목에
# DT 행이 병존하면 스윙 매도가 DT 행의 strategy_name 을 집어 DT
# ActiveTrade 를 닫아버렸다 → not_dt_strategy_clause 로 통일.
# 전략명이 NULL 인 비-DT 행(legacy/동기화 유입)은 이름으로 매칭할 수
# 없으므로 payload 에 strategy_name_not 필터를 써서 그 행을 직접 겨냥한다
# ('swing' 으로 폴백하면 NULL 행과 매칭되지 않아 유령 active 행이 남는다).
_at_filter_not_dt = False
if not _at_name:
try:
from app.models.models import ActiveTrade as _AT
from app.services.db_worker import not_dt_strategy_clause
_at_row = (await db.execute(
select(_AT)
.where(
_AT.stock_code == stock_code,
_AT.status == "active",
not_dt_strategy_clause(_AT.strategy_name),
)
.order_by(_AT.id.desc())
.limit(1)
)).scalars().first()
_at_name = _at_row.strategy_name if _at_row else None
if not _at_name:
# 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우.
# DT 로 태깅된 보유분은 기존 동작(=DT 행 종료)을 유지한다.
from app.services.db_worker import DT_STRATEGY_NAMES
_st = stock.get("strategy_type") or "swing"
if _st in DT_STRATEGY_NAMES:
_at_name = _st
else:
_at_filter_not_dt = True
except Exception:
_at_name = None
_at_filter_not_dt = False
_at_name = _at_name or stock.get("strategy_type", "swing")
if _full_exit: # [M-2] 실제 체결이 보유 전량이면 active 행을 닫는다
if _at_filter_not_dt:
from app.services.db_worker import (
DT_STRATEGY_NAMES as _DTN,
enqueue_db_write,
)
await enqueue_db_write({
"type": "close_active_trade",
"data": {
"stock_code": stock_code,
"strategy_name_not": list(_DTN),
},
})
else:
from app.services.db_worker import enqueue_close_active_trade
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
else:
from app.services.db_worker import enqueue_active_trade_upsert
# [§8.5 항목4] 추적 여부 확인용 (미추적 시 기존과 동일하게 1.0).
# 잔량 비율 자체는 아래에서 '원본 수량 기준 실제 체결' 로 계산한다.
remaining_pct, _rp_tracked = _resolve_remaining_pct_v2(stock, holding)
if not _rp_tracked:
logger.info(
f"[SELL] {stock_code}: remaining_pct 추적 불가 → 1.0 기준으로 "
f"ActiveTrade 잔량 재설정 (sell_pct={sell_pct*100:.0f}%)"
)
# [M-1] 원본 수량 기준 실제 잔량 비율. 뺄셈(remaining_pct - sell_pct)은
# 매도 수량이 현재 잔량 기준일 때와 어긋나 잔량이 오염됐다 →
# 실제 체결 수량으로 계산한다 (예: 원본 100, 잔량 70, 50주 매도 → 0.20).
# [H1 2026-10-02] 단, 홀수 수량의 내림 매도로 실제 비율이 게이트 위에
# 남으면 같은 단계가 재발동하므로 단계값(이전 − sell_pct)을 상한으로 둔다.
# 추적 불가(_rp_tracked=False)면 remaining_pct 는 1.0 으로 해석된다.
from app.services.sizing_core import remaining_pct_after_partial_sell
new_remaining = remaining_pct_after_partial_sell(
remaining_pct, sell_pct, max(0, total_qty - quantity), _original_qty
) if _original_qty > 0 else 0.0
_at_payload = {
"stock_code": stock_code,
"remaining_pct": new_remaining,
"quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지)
# [Low 3회차] 고점은 내려가면 안 된다 — 트레일링 스탑이 현재가 기준으로
# 리셋돼 느슨해지던 문제. max(기존 고점, 현재가).
"highest_since_entry": max(
float(stock.get("highest_since_entry") or 0.0), float(current_price)
),
}
if _at_filter_not_dt:
# 전략명 NULL 인 비-DT 행 대상 (이름 동등 매칭 불가).
# 대상 행이 아예 없으면 db_worker 가 필수 키 누락으로 스킵 —
# 매도 경로에서 새 active 행을 만들지 않는 것이 의도된 동작이다.
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN2
_at_payload["strategy_name_not"] = list(_DTN2)
else:
_at_payload["strategy_name"] = _at_name
# [H-1 5회차] 지정가 익절이 0체결로 취소되면 되돌릴 주문 전 상태
if _cancel_unfilled_limit:
_at_rollback = {
k: v for k, v in _at_payload.items()
if k not in ("remaining_pct", "quantity", "highest_since_entry")
}
_at_rollback.update({
"remaining_pct": remaining_pct if _rp_tracked else None,
"quantity": int(total_qty),
"highest_since_entry": (
float(stock["highest_since_entry"])
if stock.get("highest_since_entry") is not None else None
),
})
await enqueue_active_trade_upsert(_at_payload)
except Exception as _at_err:
logger.warning(f"[SELL] ActiveTrade 업데이트 실패 {stock_code}: {_at_err}")
# 인메모리 포트폴리오 캐시 반영 (UI 실시간 업데이트)
try:
from app.api.endpoints.portfolio import portfolio_cache
avg = float(holding.average_price or current_price)
await portfolio_cache.apply_sell(stock_code, quantity, current_price, avg)
# [CLAUDE.md 5.2] 분할매도: 잔여 수량이 있으면 캐시에서 제거하지 않음
# [M-3 2026-09-27] 캐시/Portfolio 행 기준 잔량은 '행 합산(_row_total_qty)'
# 에서 매도 수량을 뺀 값이어야 한다. 스윙 보유분(total_qty=_own_qty)으로
# 계산하면 스윙 전량 청산 시 remaining_qty=0 이 되어 DT 보유분까지 캐시에서
# 삭제됐다 (Portfolio DB 행은 합산 수량으로 잔존 → 캐시/DB 불일치).
remaining_qty = max(0, _row_total_qty - quantity)
if remaining_qty <= 0:
portfolio_cache.holdings = [
h for h in portfolio_cache.holdings if h.stock_code != stock_code
]
else:
# 잔여 수량 반영 (분할매도 후 남은 포지션)
for h in portfolio_cache.holdings:
if h.stock_code == stock_code:
h.quantity = remaining_qty
break
except Exception as _swallowed:
logger.warning("[SELL] portfolio_cache 반영 실패: %s", _swallowed)
# WebSocket → 프론트엔드 즉시 갱신 트리거
try:
from app.services.websocket_manager import websocket_manager
await websocket_manager.broadcast_json({
"type": "trade_executed",
"data": {
"stock_code": stock_code,
"stock_name": stock_name,
"trade_type": "SELL",
"quantity": quantity,
"price": current_price,
},
})
except Exception as _swallowed:
logger.warning("[SELL] WebSocket 갱신 트리거 실패: %s", _swallowed)
# Telegram 매도 체결 알림
try:
from app.services.telegram_notifier import telegram_notifier
asyncio.create_task(telegram_notifier.notify_trade_executed(
order_type="SELL",
stock_code=stock_code,
stock_name=stock_name,
quantity=quantity,
price=current_price,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
))
except Exception as _swallowed:
logger.warning("[SELL] 텔레그램 체결 알림 실패: %s", _swallowed)
# 3초 후 KIS 체결 확인 (슬리피지 기록 포함)
asyncio.create_task(
_verify_and_sync_execution_v2(
order_id=kis_order_id,
stock_code=stock_code,
stock_name=stock_name,
ordered_quantity=quantity,
order_price=_verify_ref_price,
order_type="SELL",
is_domestic=is_domestic,
exchange_code=exchange_code,
db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회
delay=3,
cancel_unfilled_limit=_cancel_unfilled_limit,
rollback_active_trade=_at_rollback,
order_org_no=_extract_org_no_v2(order_result),
)
)
# [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 (BUY 와 동일 사유).
return {
"action": "SELL",
"stock": stock_name,
"quantity": quantity,
"price": current_price,
"profit_loss": profit_loss_rate,
"success": success,
}
except ValueError as e:
logger.error(f"[SELL_VALIDATION] {stock_code}: {e}")
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
except Exception as e:
logger.error(f"[SELL_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
finally:
await _release_order_lock(stock_code, "SELL")
# execute_auto_trade_background 동시 실행 방지 — 스윙 스케줄러·API 트리거가 겹치면
# 일일 거래 한도(MAX_DAILY_TRADES)가 호출 단위로만 적용돼 합산 초과됨 (2026-09-17 11종목 동시 매수 사고).
_auto_trade_bg_lock = asyncio.Lock()
async def _count_swing_buys_today_v2(db: AsyncSession) -> int | None:
"""당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None.
None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다.
여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작).
"""
try:
_res = await db.execute(
select(TradeHistory.stock_code)
.where(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
TradeHistory.created_at >= get_daily_cutoff_utc(),
_swing_owned_clause_v2(TradeHistory.strategy_type),
)
.distinct()
)
_codes = {c for c in _res.scalars().all() if c}
except Exception as e:
logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}")
return None
# [M-3 4회차] db_worker 반영 지연 중인(아직 TradeHistory 에 안 보이는) 이번/직전 사이클
# 국내 스윙 매수를 합집합으로 더한다 — 지연 동안 MAX_DAILY_TRADES 를 넘겨 사는 것을 방지.
# swing/entry_service 의 듀얼 선확인도 이 함수를 그대로 쓴다.
_codes |= _domestic_recent_swing_buy_codes_v2()
return len(_codes)
def _regime_limits_v2(market_regime) -> tuple[int, float]:
"""check_market_regime() 결과 → (국면 max_trades, position_multiplier).
- None(BEAR_MARKET_FILTER_ENABLED=False 로 조회 안 함): 기존과 동일하게 제한 없음
(MAX_DAILY_TRADES, 1.0).
- dict 인데 regime_200d 가 없음 = 판정 실패 → (0, 0.0) fail-closed + error 로그.
- max_trades 키가 없으면 MAX_DAILY_TRADES(장외 분기 등 기존 동작), 해석 불가면 0.
- position_multiplier 키가 없으면 1.0(기존), 해석 불가면 0.0.
"""
if market_regime is None:
return int(settings.MAX_DAILY_TRADES), 1.0
if not isinstance(market_regime, dict) or not market_regime.get("regime_200d"):
logger.error(
f"[AUTO_TRADE_BG] 🚨 시장 국면 판정값 없음({market_regime!r:.200}) — "
f"신규 진입 차단(fail-closed)"
)
return 0, 0.0
_mt = market_regime.get("max_trades")
try:
max_trades = int(settings.MAX_DAILY_TRADES) if _mt is None else int(_mt)
except (TypeError, ValueError):
logger.error(f"[AUTO_TRADE_BG] max_trades={_mt!r} 해석 불가 — 신규 진입 차단")
max_trades = 0
_pm = market_regime.get("position_multiplier")
try:
pm = 1.0 if _pm is None else float(_pm)
except (TypeError, ValueError):
logger.error(f"[AUTO_TRADE_BG] position_multiplier={_pm!r} 해석 불가 — 0 배 적용")
pm = 0.0
return max(0, max_trades), max(0.0, pm)
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.
[동작 흐름]
1. 시장 국면(BEAR_MARKET_FILTER) 조회 → 최대 거래 수·포지션 배율 결정
2. recommendations_by_rule 그룹을 단일 unique_stocks dict로 정규화
- 동일 종목이 여러 룰에 걸리면 우선순위(rule_priority) 낮은 쪽 유지
3. (rule_priority ASC, score DESC)로 정렬 → 상위 max_trades개 처리
4. 종목별 execute_auto_trade() 호출 (0.5초 간격 → KIS API 레이트 리밋 준수)
"""
if not isinstance(recommendations_response, dict):
logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response)
return []
if _auto_trade_bg_lock.locked():
logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)")
return []
async with _auto_trade_bg_lock:
return await _execute_auto_trade_background_locked(recommendations_response, db)
async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession):
# trade_results 는 except 절에서도 참조하므로 try 밖에서 바인딩한다(항상 list 반환 보장).
trade_results: list = []
try:
total_processed = 0
# 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소)
market_regime = None
if settings.BEAR_MARKET_FILTER_ENABLED:
market_regime = await check_market_regime()
# [H3 2026-10-02] 필터가 켜져 있는데 국면 판정값이 없거나 형식이 틀리면
# (판정 실패) fail-closed. 기존 기본값(20건·1.0배)은 실패를 정상장으로 취급했다.
regime_max_trades, position_multiplier = _regime_limits_v2(market_regime)
max_trades = min(settings.MAX_DAILY_TRADES, regime_max_trades)
# [M-2 4회차] 아래 게이트(국면·일일 한도·건수 조회 실패)는 **신규 매수만** 막는다.
# 조기 return 하면 추천 목록에 있는 보유 종목의 청산 판정까지 사라졌다 → max_trades=0
# 으로 두고 루프에서 보유 종목만 _exit_only 로 평가한다(§8.1-11).
_buy_block_reason: str | None = None
if max_trades <= 0:
_buy_block_reason = "regime_gate"
logger.info(
f"[AUTO_TRADE_BG] 국면 게이트 — max_trades=0 "
f"(regime={market_regime.get('regime') if isinstance(market_regime, dict) else None}) "
f"— 신규 진입 스킵, 보유 종목 청산 판정만 수행"
)
# 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용
# (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨)
# [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL
# 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준).
# [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를
# 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2
# (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다.
_bought_today = await _count_swing_buys_today_v2(db) if _buy_block_reason is None else 0
if _bought_today is None:
_buy_block_reason = "daily_count_unverifiable"
max_trades = 0
logger.error(
f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 "
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 "
f"이번 사이클 신규 진입 차단(fail-closed). 보유 종목 청산 판정은 계속"
)
elif _buy_block_reason is None:
max_trades = max(0, max_trades - _bought_today)
if max_trades == 0:
_buy_block_reason = "daily_limit"
logger.info(
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 "
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵, "
f"보유 종목 청산 판정만 수행"
)
if _buy_block_reason is not None:
max_trades = 0
# [2026-09-27] held_stocks 집합은 파일 전체에서 미사용이라 전용 SELECT 와 함께 제거.
# 보유 여부는 execute_auto_trade() 내부에서 종목별로 직접 조회한다.
# recommendations_by_rule 정규화 (list 또는 dict 모두 처리)
rec_groups = recommendations_response.get("recommendations_by_rule", [])
if isinstance(rec_groups, dict):
normalized_list = []
for name, group in rec_groups.items():
if isinstance(group, dict):
normalized_list.append(group)
else:
normalized_list.append({"rule_name": name, "stocks": group})
rec_groups = normalized_list
# 종목 코드 기준으로 중복 제거 (rule_priority 낮은 쪽 = 더 중요한 규칙 우선)
unique_stocks: dict = {}
for rule_group in rec_groups:
if not isinstance(rule_group, dict):
logger.warning("[AUTO_TRADE_BG] Invalid rule_group: %r", rule_group)
continue
stocks_list = rule_group.get("stocks")
if not isinstance(stocks_list, list):
logger.warning("[AUTO_TRADE_BG] Invalid stocks list: %r", stocks_list)
continue
for stock in stocks_list:
if not isinstance(stock, dict):
logger.warning("[AUTO_TRADE_BG] Skipping non-dict stock entry: %r", stock)
continue
stock_code = stock.get("stock_code")
if stock_code not in unique_stocks:
unique_stocks[stock_code] = stock
elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999):
unique_stocks[stock_code] = stock # 더 높은 우선순위 룰로 교체
# 우선순위↑(숫자↓), 점수↓ 순으로 정렬 후 max_trades개만 처리
sorted_stocks = sorted(
unique_stocks.values(),
key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)),
)
# [M-1 3회차 2026-10-02] 슬롯 = 'BUY & success' 건수. 기존 `sorted_stocks[:max_trades]` 는
# SKIP/HOLD(보유·당일매수·쿨다운 등)도 슬롯을 소비해, 상위에 이미 산 종목이 남으면 신규
# 후보를 평가조차 못 했다. 전체 후보를 순회하되 KIS 호출량은 '신규 후보 평가 상한'으로 묶고,
# 매수 슬롯 소진 뒤에는 보유 종목만 청산 판정용으로 계속 평가한다(_exit_only).
# DB-READ: 보유 종목 코드 SELECT 전용
_held_res = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0))
_held_set = {c for c in _held_res.scalars().all() if c}
_eval_cap = max(1, int(settings.AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE))
_buys_ok = 0
_evaluated_new = 0
_skipped_cooldown = 0
_skipped_cap = 0
# [L-3 4회차] 순회 순서 = 보유 종목(청산 판정 우선, 원래 정렬) → 신규 후보.
# 신규 후보가 평가 상한보다 많으면 시작 오프셋을 사이클마다 회전해, 상위 HOLD 종목이
# 매 사이클 상한을 전부 차지해 하위 후보가 영영 평가되지 않는 것을 막는다.
global _eval_rotation_offset
_held_list = [x for x in sorted_stocks if isinstance(x, dict) and x.get("stock_code") in _held_set]
_new_list = [x for x in sorted_stocks if not (isinstance(x, dict) and x.get("stock_code") in _held_set)]
if len(_new_list) > _eval_cap and max_trades > 0:
_off = _eval_rotation_offset % len(_new_list)
_new_list = _new_list[_off:] + _new_list[:_off]
_eval_rotation_offset = (_off + _eval_cap) % len(_new_list)
else:
_eval_rotation_offset = 0
for stock in _held_list + _new_list:
if not isinstance(stock, dict):
logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock)
continue
_code = stock.get("stock_code")
_is_held = _code in _held_set
# [L-2 4회차] 계좌 단위 거부 쿨다운 중이면 신규 매수 전부 보류(청산 판정은 계속)
_slots_full = _buys_ok >= max_trades or _account_reject_cooldown_remaining_v2() > 0
if not _is_held:
if _slots_full:
continue
if _order_reject_cooldown_remaining_v2(_code) > 0:
_skipped_cooldown += 1
continue
if _evaluated_new >= _eval_cap:
_skipped_cap += 1
continue
_evaluated_new += 1
try:
stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입
stock["_regime_str"] = (
(market_regime.get("regime_200d") or "bear")
if isinstance(market_regime, dict) else "bear"
)
if _slots_full:
stock["_exit_only"] = True # 보유 종목: 청산 판정만, 추가매수 금지
result = await execute_auto_trade(stock, db)
total_processed += 1
if result["action"] in ["BUY", "SELL"]:
trade_results.append(result)
if result["action"] == "BUY" and result.get("success"):
_buys_ok += 1
else:
# [관측성] 매수 후보가 왜 진입 안 됐는지 사유를 남긴다. 기존엔 HOLD/SKIP
# 사유가 로그에 안 남아 "매수 실행" 후 조용히 스킵되는 이유를 추적하기
# 어려웠다(예: 플레이북 FOMC 차단, 상관/뉴스/쿨다운, 수량 0 등).
logger.info(
f"[AUTO_TRADE_BG] {stock.get('stock_code')} 진입 보류: "
f"action={result.get('action')} reason={result.get('reason', '미상')}"
)
await asyncio.sleep(0.5) # KIS API 레이트 리밋: 초당 2회 이하 권장
except Exception as e:
logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}")
continue
logger.info(
f"[AUTO_TRADE_BG] 사이클 요약: 후보 {len(sorted_stocks)} / 신규 평가 {_evaluated_new} "
f"(상한 {_eval_cap}, 초과 {_skipped_cap}) / 매수 성공 {_buys_ok}/{max_trades} / "
f"거부 쿨다운 스킵 {_skipped_cooldown}"
)
# [관측성 2026-09-27] 체결 결과를 호출부로 반환한다. 기존엔 trade_results 를
# 만들어놓고 return 이 없어 항상 None 이 나갔고, swing/entry_service.py 의
# isinstance(result, list) 분기가 영구 False → "진입 n건" 로그가 사라졌다.
# 담기는 값은 action 이 BUY/SELL 인 dict 뿐이다(SKIP/HOLD/*_FAILED 는 제외).
return trade_results
except Exception as e:
logger.error(
f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — "
f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}"
)
# 예외 시점까지 실제로 체결된 건은 유실 없이 보고한다(부분 결과 반환).
return trade_results
async def _background_sync_portfolio():
try:
from app.api.endpoints.portfolio import sync_portfolio
async with AsyncSessionLocal() as session:
await sync_portfolio(session, force=True)
except Exception as e:
logger.error(f"[PORTFOLIO_SYNC] 백그라운드 동기화 실패: {type(e).__name__}: {e}")
async def _delayed_sync_portfolio(delay_seconds: int = 3):
try:
await asyncio.sleep(delay_seconds)
from app.api.endpoints.portfolio import sync_portfolio
async with AsyncSessionLocal() as session:
try:
await sync_portfolio(session, force=True)
except Exception as inner_e:
logger.error(f"[PORTFOLIO_SYNC] 실행 실패: {type(inner_e).__name__}: {inner_e}")
except Exception as e:
logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}")
def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float:
"""ML 매수/HOLD 게이트 비교용 점수 산출 (0~100 스케일 기준).
[배경 — 2026-09-28 스케일 불일치 수정]
market_analyzer.py(:603-608) 는 룰 점수를 0~100 으로 정규화한 뒤
`rule*RULE_WEIGHT + ml*ML_WEIGHT` 로 블렌딩하고, 그 결과를 다시 0~15 로
축소해 StockAnalysis.score 에 저장한다. 즉 trade_executor 가 받는
stock["score"] 는 **이미 ML 이 반영된 0~RULE_SCORE_MAX 스케일** 값이다.
(ml_score 키가 실린 모든 추천 경로 = recommendations.py:45 _stock_analysis_to_dict /
recommendations.py:589 rec.model_dump() 가 동일하게 이 값을 전달한다.)
그런데 기존 게이트는 그 0~15 값을 정규화 없이 0~100 ML 점수와 다시 가중합해
① 룰 기여 상한이 15×0.3=4.5 (임계 60 의 7.5%) 로 사실상 소멸
② ml_score 를 이중 계산
하는 두 결함을 안고 있었다.
ML_SCORE_NORMALIZE_ENABLED=False(기본) → 기존 계산식을 비트 단위로 그대로 유지.
True → 재블렌딩을 제거하고 0~RULE_SCORE_MAX 를 0~100 으로 환산해 임계와 직접 비교.
(ML 은 이미 score 안에 ML_WEIGHT 비중으로 반영되어 있다.)
임계값·가중치(ML_ENSEMBLE_BUY_THRESHOLD/RULE_WEIGHT/ML_WEIGHT)는 무변경.
"""
if not settings.ML_SCORE_NORMALIZE_ENABLED:
# 기존 동작 (0~15 룰점수 + 0~100 ML점수 가중합) — 비트 동일
return (
rule_score * settings.ML_ENSEMBLE_RULE_WEIGHT
+ ml_score * settings.ML_ENSEMBLE_ML_WEIGHT
)
rule_max = float(settings.RULE_SCORE_MAX or 15)
if rule_max <= 0:
rule_max = 15.0
return (rule_score / rule_max) * 100.0
def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list:
"""당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준)."""
return [
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
or_(
and_(
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
TradeHistory.executed_at >= cutoff_time,
),
and_(
TradeHistory.status == "PENDING",
TradeHistory.created_at >= cutoff_time,
),
),
]
async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool:
"""당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용.
DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3).
조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일).
"""
from app.services.db_worker import not_dt_strategy_clause
try:
_res = await db.execute(
select(TradeHistory.id).where(
*_today_sell_conditions_v2(stock_code, cutoff_time),
not_dt_strategy_clause(TradeHistory.strategy_type),
).limit(1)
)
return _res.first() is not None
except Exception as e:
logger.warning(
f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 "
f"(전량 청산은 허용): {type(e).__name__}: {e}"
)
return True
async def execute_auto_trade(stock: dict, db: AsyncSession):
"""종목 단건 매매 판단 및 실행.
공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후
_execute_buy / _execute_sell 에 위임한다.
[필터 체크 순서]
1. 유효성 검사 (stock_code, current_price)
2. 국내/해외 구분 (stock_code 6자리 숫자 → 국내)
3. 가격 필터 (국내 5,000원↑ / 미국 $4↑)
4. MDD 일시정지 여부 (MDD < -10% 시 신규진입 중단)
5. 연속손실 밴 여부 (3회 연속 손실 → 30일 진입 금지)
6. 최대 보유 종목 수 초과 여부 (settings.MAX_HOLDINGS_COUNT)
7. 당일 매도 후 재매수 쿨다운 (당일 SELL COMPLETED 기록 존재 시 재매수 금지)
8. 신호 분석: BUY_KEYWORDS / SELL_KEYWORDS / BLOCK_KEYWORDS 매칭
9. ML 가중치 통합 (ML_ENSEMBLE_ENABLED 시 rule 40% + ML 60% 합산)
10. ATR 강제 손절 (신호 없어도 현재가 ≤ avg - 2.5×ATR 이면 강제 매도)
11. _execute_buy 또는 _execute_sell 위임
"""
try:
stock_code = stock.get("stock_code")
stock_name = stock.get("stock_name")
current_price = float(stock.get("current_price", 0))
signals = stock.get("signals", [])
if not stock_code or current_price <= 0:
return {"action": "SKIP", "reason": "invalid_data"}
# ── 비상 정지 (TRADING_HALTED) 가드 ─────────────────────────────
if settings.TRADING_HALTED:
return {"action": "SKIP", "reason": "trading_halted", "stock": stock_name}
# 국내(6자리 숫자) vs 해외 판별 — exchange_detector 모듈 사용
is_domestic = not is_us_stock(stock_code)
if is_domestic:
exchange_code = "KRX"
else:
exchange_code = stock.get("exchange_code") or detect_exchange(stock_code)
# ── 가격 필터 (CLAUDE.md 5.4: ETF/리츠 예외) ─────────────────────────
# [M-6 2026-10-02] 판정만 여기서 하고, 적용은 보유 조회 뒤 `_guard` 로 한다.
# 기존엔 즉시 SKIP 반환해 5,000원 이하로 떨어진 보유 종목의 손절·청산 판정이
# 막혔다(§8.1-11 청산 우선 위반).
_ETF_KEYWORDS = (
"ETF", "KODEX", "TIGER", "KBSTAR", "ARIRANG", "SOL",
"HANARO", "ACE", "리츠", "REIT", "인프라",
)
_is_etf = stock_name and any(kw in (stock_name or "").upper() for kw in _ETF_KEYWORDS)
if is_domestic:
_is_penny = current_price <= settings.MIN_PRICE_KRW and not _is_etf # CLAUDE.md 5.4: "초과"
else:
_is_penny = current_price <= settings.MIN_PRICE_USD and not _is_etf # CLAUDE.md 5.4: "초과"
# ── 현재 보유 조회 (리스크 가드보다 먼저) ────────────────────────────
# [2026-09-27 M3] MDD/밴/보유수/쿨다운 가드는 '신규 매수'에만 적용해야 한다.
# 기존에는 즉시 SKIP 반환해 보유 종목의 check_exit_signal·ATR 강제 손절에
# 도달조차 못했다(MDD -10%·보유 20종목 = 가장 청산이 필요한 국면에서 손절 불가).
# → 보유가 있으면 사유를 _buy_blocked_reason 에 담고 청산 판정까지 진행한다.
result = await db.execute(
select(Portfolio).where(
Portfolio.stock_code == stock_code,
Portfolio.quantity > 0,
)
)
holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
_buy_blocked_reason: str | None = None
# [2026-09-27 H-1] 당일 SELL 체결 여부. 보유 종목의 청산 판정은 계속 진행하되,
# '부분매도'가 사이클마다 반복되는 것(계단식 청산 + 왕복 수수료)을 막기 위한 플래그.
_sold_today = False
def _guard(reason: str):
"""보유 없으면 즉시 SKIP dict 반환, 보유 중이면 매수만 차단하고 계속 진행."""
nonlocal _buy_blocked_reason
if holding is None:
return {"action": "SKIP", "reason": reason, "stock": stock_name}
if _buy_blocked_reason is None:
_buy_blocked_reason = reason
return None
if _is_penny:
_r = _guard("penny_stock")
if _r is not None:
return _r
# ── MDD / 손실추적 / 보유수 ──────────────────────────────────────────
if not await mdd_manager.can_open_new_position():
# MDD -10% 이상 낙폭 → 신규 진입 중단
_r = _guard("mdd_pause")
if _r is not None:
return _r
if await loss_tracker.is_banned_safe(stock_code):
# 3회 연속 손실 종목 → 30거래일 진입 금지
_r = _guard("consecutive_loss_ban")
if _r is not None:
return _r
# ── 유입 격리분 추가매수 차단 ────────────────────────────────────────
# [2026-09-30] 유입 잔고(strategy_type='external') 행에 스윙 매수를 얹으면
# Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로(CLAUDE.md §3.5) 행이
# swing 으로 승격되고 **유입분까지 스윙 자동 손절 대상이 된다.**
# 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
# → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 의 정면 위반이다.
#
# 여기서 막으면 그 혼재가 **발생하지 않는다** — 유입분 격리를 지키면서
# 시스템 매수분이 관리 밖에 남는 트레이드오프도 함께 사라진다(애초에 안 산다).
# `_guard` 를 쓰므로 **청산 판정은 그대로 통과**한다(§8.1-11).
# DT 경로(daytrading/entry_service)는 이 함수를 타지 않고, DT 는 인메모리로
# 자기 수량을 관리하므로 차단 대상이 아니다.
if holding is not None and (holding.strategy_type or "swing") == "external":
logger.warning(
f"🚧 유입 격리분 추가매수 차단: {stock_name}({stock_code}) "
f"보유 {holding.quantity}주 strategy_type='external' — "
f"매수하면 행 전체가 swing 으로 승격돼 유입분이 자동 손절 대상이 된다"
)
_r = _guard("external_inflow_holding")
if _r is not None:
return _r
elif holding is not None:
# [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭.
# manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면
# 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아
# SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11).
_own_block = _sell_ownership_block_reason_v2(holding, stock)
if _own_block is not None:
logger.warning(
f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) "
f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' "
f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 "
f"추가매수분이 SL/TP 없이 남는다"
)
_r = _guard(f"non_swing_holding:{_own_block}")
if _r is not None:
return _r
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
# [§4 2026-10-02] 폴백은 유지하되 조용히 넘기지 않는다(warning, 레이트리밋).
_adaptive_max_holdings = settings.MAX_HOLDINGS_COUNT
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adaptive = get_current_adaptive_params()
if not _adaptive.fallback_used:
_adaptive_max_holdings = _adaptive.max_holdings
except Exception as _mh_err:
warn_throttled(
logger, "trade_executor:max_holdings_overlay",
"[TRADE] 적응형 max_holdings 조회 실패 — MAX_HOLDINGS_COUNT 폴백: %s", _mh_err,
)
# [M-3 2026-10-02] 같은 사이클에 방금 산(아직 Portfolio 미반영) 종목을 합산.
_held_codes = {getattr(_h, "stock_code", None) for _h in held_result.scalars().all()}
_held_count = len(_held_codes | _domestic_recent_cycle_buy_codes_v2())
if _held_count >= _adaptive_max_holdings:
# 동시 보유 한도 초과 (적응형: max_holdings)
_r = _guard("max_holdings_reached")
if _r is not None:
return _r
# ── 당일 매도 후 재매수 쿨다운 ───────────────────────────────────────
# 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3)
# [2026-09-27 §8.5 항목5] PENDING(접수만 되고 미체결) SELL 주문을 포함한다.
# 근거: models.py:141 status(String), :157 created_at(DateTime, default=now_kst),
# :158 executed_at(DateTime, nullable) — 둘 다 KST naive 저장.
# _execute_sell(trade_executor.py:1303·1329-1330)은 rt_cd=="1" 일 때
# status="PENDING", executed_at=None 으로 기록하므로 executed_at 날짜
# 필터만으로는 PENDING 행이 전부 탈락한다 → 지정가 부분매도가 미체결인
# 동안 _sold_today=False 로 남아 사이클마다 중복 부분매도가 쌓였다.
# PENDING 은 created_at 으로 '당일'만 인정 → 체결·취소 없이 남은 과거 유령 행이
# 쿨다운을 영구히 걸지 않는다.
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
recent_sell = await db.execute(
select(TradeHistory).where(*_today_sell_conditions_v2(stock_code, cutoff_time))
)
if recent_sell.first():
# 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작).
_r = _guard("cooldown_sell_today")
if _r is not None:
return _r
# [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다.
# DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다.
_sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time)
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
# 읽기 전용 SELECT만 수행 (DB 쓰기 없음, swing/exit_service.py의 자체 추적과 무충돌).
if holding is not None and stock.get("remaining_pct") is None:
try:
from app.models.models import ActiveTrade
# [2026-09-27 M6] swing/position_manager.get_swing_active_trade 및
# db_worker 와 동일 패턴: day_trade 행 제외 + 최신 행 우선.
# (같은 종목에 swing/day_trade 행이 동시에 active 일 때 DT 행의
# remaining_pct 가 스윙 청산 판단에 주입되는 것을 방지)
# [2026-09-27 §8.5 항목3] `!= "day_trade"` 는 SQL 3값 논리로
# strategy_name IS NULL 인 legacy 스윙 행까지 탈락시켜, 잔량 추적
# 소스를 잃고 remaining_pct 가 매 사이클 1.0 으로 뭉개졌다.
# → db_worker.not_dt_strategy_clause() 로 3곳을 동일 표현으로 통일.
from app.services.db_worker import not_dt_strategy_clause
_at_result = await db.execute(
select(ActiveTrade)
.where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
.order_by(ActiveTrade.id.desc())
.limit(1)
)
_active_trade = _at_result.scalars().first()
if _active_trade is not None and _active_trade.remaining_pct is not None:
stock["remaining_pct"] = float(_active_trade.remaining_pct)
# [Low 3회차] 부분매도 시 고점을 낮추지 않도록 기존 고점을 함께 전달
_hse = getattr(_active_trade, "highest_since_entry", None) if _active_trade is not None else None
if _hse is not None and stock.get("highest_since_entry") is None:
stock["highest_since_entry"] = float(_hse)
except Exception as _at_err:
# 조회 실패 = 잔량 추적 불가 → _resolve_remaining_pct_v2 가 부분매도를
# 차단한다. 조용히 넘기면 원인 추적이 불가하므로 최소 warning.
logger.warning(
f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패 "
f"(부분매도 차단, 전량 청산만 허용): {_at_err}"
)
# ── 신호 분석 (Enum 기반) ─────────────────────────────────────────────
text_signals = " ".join(signals)
try:
score_val = float(stock.get("score", 0) or 0)
except Exception:
score_val = 0.0
# "거래량부족" 신호 제거 후 매수 판단 (거래량 부족 단독으로는 매수 차단)
signals_without_volume_weak = [s for s in signals if "거래량부족" not in s]
text_signals_filtered = " ".join(signals_without_volume_weak)
should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS)
if not signals_without_volume_weak and signals:
should_buy = False # 거래량부족 신호만 있으면 매수 금지
should_sell = any(k in text_signals for k in SELL_KEYWORDS)
# ── ML 가중치 통합 (기본: 규칙 30% + ML 70%) ─────────────────────────
# weighted_score = score × RULE_WEIGHT + ml_score × ML_WEIGHT
# ML BUY: weighted_score ≥ threshold AND confidence ≥ min → should_buy = True
# ML SELL: confidence ≥ 0.5 → should_sell = True
# ML HOLD: weighted_score 낮으면 should_buy = False 강제
ml_score_trade = stock.get("ml_score")
ml_confidence_trade = stock.get("ml_confidence")
ml_signal_trade = stock.get("ml_signal")
if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None:
try:
# [2026-09-28] 스케일 불일치(0~15 룰점수 vs 0~100 임계) 및 ML 이중
# 계산 수정. ML_SCORE_NORMALIZE_ENABLED=False 면 기존 계산과 동일.
# HOLD 게이트(*0.7)도 같은 weighted_score 를 쓰므로 스케일 일관.
weighted_score = _ml_gate_score_v2(score_val, float(ml_score_trade))
if (
ml_signal_trade == "BUY"
and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD
and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN
):
should_buy = True
elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5:
# [2026-10-02] ML 단독 SELL 은 보유분 전량매도(_exit_sell_pct=1.0, 룰
# 청산 판정 생략)를 유발한다. 운영 LSTM 은 재학습 전 사실상 난수
# (CLAUDE.md §8.5-20)이므로 ML_SELL_SIGNAL_ENABLED(기본 False)로 막는다.
# 룰 기반 청산(check_exit_signal·ATR 손절·SELL_KEYWORDS)은 그대로.
if settings.ML_SELL_SIGNAL_ENABLED:
should_sell = True
elif holding is not None:
warn_throttled(
logger, "trade_executor:ml_sell_disabled",
"[ML_WEIGHT] %s: ML SELL 무시(재학습 전, ML_SELL_SIGNAL_ENABLED=False) "
"conf=%s — 룰 기반 청산 판정으로 진행",
stock_code, ml_confidence_trade,
)
elif (
ml_signal_trade == "HOLD"
and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7
):
should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제
except Exception as e:
warn_throttled(logger, "trade_executor:2366", f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}")
has_rule = stock.get("rule_id") is not None
# ── BLOCK 키워드(과열·이격) 판정 ─────────────────────────────────────
# [2026-09-27 H1-A] 기존에는 BLOCK 감지 시 즉시 HOLD를 return 해
# 아래 청산 판정(check_exit_signal·ATR 강제 손절)에 도달하지 못했다.
# 보유 종목이 과열이면 오히려 매도해야 하는 국면인데 손절·전량익절·트레일링이
# 전부 스킵됐다. → 조기 return 대신 should_buy 만 끄고 청산 판정까지 진행한다.
# 판정 임계값·BLOCK_KEYWORDS 목록은 무변경(제어 흐름만 수정).
_block_overheat_reason: str | None = None
if any(b in text_signals for b in BLOCK_KEYWORDS):
_block_overheat_reason = (
"disparity_overheated" if has_rule else "overheat_no_rule"
)
should_buy = False
if holding is None:
# 보유 없는 종목은 청산 판정이 무의미 → 기존과 동일하게 즉시 HOLD 반환
return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason}
if has_rule and _block_overheat_reason is None:
# 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호)
# ML HOLD는 신뢰도 60% 이상일 때만 rule 매수를 차단
# (신뢰도 40% 미만의 약한 HOLD는 rule 판단을 우선)
ml_hold_active = (
settings.ML_ENSEMBLE_ENABLED
and ml_signal_trade == "HOLD"
and not should_buy
and (ml_confidence_trade or 0) >= 0.6
)
if not ml_hold_active:
should_buy = True
# ML HOLD이면 매수 억제 — 단, 신뢰도 60% 이상의 '확신 있는' HOLD만 차단.
# [FIX] 기존에는 신뢰도 무관하게 차단해, 일봉 데이터 부족으로 ML 피처 추출이
# 실패한 경우(signal=HOLD, confidence=0 = 사실상 "의견 없음")까지 모든 매수를
# 막아버렸다(수개월간 거래 0의 근본 원인). 위 ml_hold_active(1417~) 로직과 동일하게
# confidence>=0.6 조건을 적용해, 저신뢰/실패 HOLD는 룰 기반 판단을 막지 않도록 한다.
if (
ml_signal_trade == "HOLD"
and settings.ML_ENSEMBLE_ENABLED
and (ml_confidence_trade or 0) >= 0.6
):
should_buy = False
logger.info(f"[TRADE] {stock_code}: ML HOLD(conf≥0.6) → 매수 차단")
if should_buy and not has_rule:
# 비규칙 종목: 강한 신호 OR (신호 2개 이상 AND 점수 4점 이상) 필수
has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS)
matched_count = sum(1 for k in BUY_KEYWORDS if k in text_signals_filtered)
if not has_strong_signal and (matched_count < 2 or score_val < 4):
should_buy = False
# ── CLAUDE.md 6.3: check_exit_signal 연동 (보유 종목 청산 판단) ─────
# [2026-03-15] 기존에 텍스트 신호 매칭에만 의존하던 매도 판단을
# mean_reversion_strategy.check_exit_signal()로 보완.
# RSI/BB/ATR 기반 부분매도(50%)/전량매도/트레일링스탑 규칙 적용.
_exit_signal = None
_exit_sell_pct = 1.0
if holding and not should_sell:
avg_price = float(holding.average_price or 0)
atr = float(stock.get("atr", 0) or 0)
if avg_price > 0:
# 차트 데이터가 있으면 RSI/BB 기반 청산 판단
closes = stock.get("closes", [])
rsi_val = stock.get("rsi")
bb_data = stock.get("bb") # (upper, middle, lower)
# RSI/BB가 직접 전달되지 않은 경우 closes에서 계산
if rsi_val is None and len(closes) >= 15:
rsi_val = calculate_rsi(closes, 14)
if bb_data is None and len(closes) >= 20:
bb_data = calculate_bollinger(closes, 20)
if rsi_val is not None and bb_data is not None and atr > 0:
bb_upper, bb_middle, bb_lower = bb_data if isinstance(bb_data, (tuple, list)) else (0, 0, 0)
# remaining_pct: 현재 보유 비율 (1차 익절 전 = 1.0, 후 = 0.5 등)
# [§8.5 항목4] None = '추적 소스 없음' 을 명시 상태로 분리.
remaining_pct, _remaining_tracked = _resolve_remaining_pct_v2(stock, holding)
trailing_high = float(stock.get("high_price") or current_price)
lowest_since = float(stock.get("lowest_since_entry") or avg_price)
_exit_signal = check_exit_signal(
regime=MarketRegime.BULL if stock.get("_regime_str", "bear") == "bull" else MarketRegime.BEAR,
rsi=rsi_val,
close=current_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,
)
# [§8.5 항목4①] 잔량 추적 불가(ActiveTrade 행 없음/오염) 상태에서는
# 부분매도를 채택하지 않는다 — 매 사이클 remaining_pct=1.0 으로
# 재평가되어 계단식 분할매도 + 왕복 수수료가 무한 반복된다.
# 전량 청산(손절·RSI 전량·트레일링)은 그대로 허용한다.
if (
_exit_signal is not None
and _exit_signal.sell_pct < 1.0
and not _remaining_tracked
):
logger.info(
f"[EXIT_SIGNAL] {stock_code}/{stock_name} 부분매도 차단: "
f"remaining_pct 추적 불가(ActiveTrade active 행 없음 — "
f"브로커 잔고 sync/legacy 보유분) "
f"sell_pct={_exit_signal.sell_pct*100:.0f}%, "
f"reason={_exit_signal.reason} — 전량 청산 신호만 허용"
)
_exit_signal = None
if _exit_signal:
should_sell = True
_exit_sell_pct = _exit_signal.sell_pct
signals = list(signals) + [f"전략청산: {_exit_signal.reason}"]
logger.info(
f"[EXIT_SIGNAL] {stock_code}/{stock_name}: "
f"{_exit_signal.action} {_exit_signal.sell_pct*100:.0f}% — {_exit_signal.reason}"
)
# ATR 기반 강제 손절 (check_exit_signal에서 미처리된 경우 폴백)
if not should_sell and atr > 0:
atr_stop_price = avg_price - 2.5 * atr
if current_price <= atr_stop_price:
should_sell = True
_exit_sell_pct = 1.0 # 손절은 전량
signals = list(signals) + [f"ATR 손절: {current_price:.0f} ≤ {atr_stop_price:.0f}"]
# ── 뉴스 감성 필터 (매수 전) ───────────────────────────────────────
# NEWS_ENABLED 시 종목 뉴스 점수 조회 → 강한 악재 차단 / 약한 악재 포지션 축소
_news_position_scale = 1.0 # 뉴스에 의한 포지션 배율 (기본 100%)
# [2026-09-27 H1] 청산 대상(보유+매도신호)은 뉴스 필터를 타지 않는다 —
# news_negative SKIP 이 손절보다 먼저 반환되면 청산이 또 막힌다.
if should_buy and not (should_sell and holding) and settings.NEWS_ENABLED:
try:
from app.services.news_sentiment import get_stock_news_score
news_score = await get_stock_news_score(stock_code)
if news_score <= settings.NEWS_BLOCK_SCORE:
logger.info(
f"[NEWS_FILTER] {stock_code}/{stock_name} 진입 차단: "
f"news_score={news_score:.3f} <= {settings.NEWS_BLOCK_SCORE}"
)
return {"action": "SKIP", "reason": "news_negative", "stock": stock_name,
"news_score": news_score}
elif news_score <= settings.NEWS_REDUCE_SCORE:
_news_position_scale = 0.5 # 포지션 50% 축소
logger.info(
f"[NEWS_FILTER] {stock_code}/{stock_name} 포지션 50% 축소: "
f"news_score={news_score:.3f} <= {settings.NEWS_REDUCE_SCORE}"
)
except Exception as e:
warn_throttled(logger, "trade_executor:2520", f"[NEWS_FILTER] {stock_code} 뉴스 스코어 조회 오류 (무시): {e}")
# 뉴스 포지션 배율을 stock dict에 전달 (_execute_buy에서 참조)
if _news_position_scale < 1.0:
stock["_news_position_scale"] = _news_position_scale
# ── 매매 실행 위임 ────────────────────────────────────────────────────
# [2026-09-27 H1] 청산은 항상 매수보다 우선한다.
# 기존에는 should_buy 를 먼저 평가해, 보유 종목이 당일 룰 추천에 포함되면
# (rule 기반은 BLOCK/ML HOLD 만 없으면 should_buy=True 강제)
# 손절·전량익절·트레일링이 전부 무력화되고 물타기 매수가 나갔다.
# [2026-09-27 H-1] 당일 이미 SELL 체결된 보유 종목의 '부분매도' 반복 차단.
# _guard() 가 보유 종목의 cooldown_sell_today 조기 SKIP 을 제거한 뒤부터,
# ActiveTrade 행이 없는 보유분(브로커 sync·legacy strategy_name IS NULL)은
# remaining_pct 가 매 사이클 1.0 으로 간주되어 SELL_PARTIAL(0.5)이 무한 반복됐다.
# → 전량 청산(손절·RSI≥70·트레일링)은 그대로 허용하고, 부분매도만 당일 1회로 제한한다.
if _sold_today and should_sell and holding and _exit_sell_pct < 1.0:
logger.info(
f"[TRADE] {stock_code}/{stock_name} 부분매도 차단: cooldown_sell_today "
f"(당일 매도 기록 존재, sell_pct={_exit_sell_pct*100:.0f}%, "
f"reason={(_exit_signal.reason if _exit_signal else ' '.join(signals))}) "
f"— 전량 청산 신호만 허용"
)
return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name}
if should_buy and should_sell and holding:
logger.warning(
f"[TRADE_CONFLICT] {stock_code}/{stock_name}: 동일 사이클에 매수·매도 신호 동시 발생 "
f"→ 청산 우선 실행. has_rule={has_rule} score={score_val} "
f"ml_signal={ml_signal_trade} ml_conf={ml_confidence_trade} "
f"exit={(_exit_signal.reason if _exit_signal else None)} "
f"sell_pct={_exit_sell_pct} signals={signals}"
)
if should_sell and holding:
logger.info(f"[TRADE] 📉 매도 실행: {stock_code}/{stock_name} @ {current_price:,.0f} ({_exit_sell_pct*100:.0f}%)")
return await _execute_sell(
stock, db, holding, current_price, signals,
is_domestic, exchange_code, stock_code, stock_name,
sell_pct=_exit_sell_pct, # [CLAUDE.md 5.2] 분할매도 비율 전달
)
if should_buy:
# [M-1 3회차] 사이클 매수 슬롯 소진 후에는 보유 종목 청산 판정만 수행한다.
if stock.get("_exit_only"):
return {"action": "SKIP", "reason": "buy_slots_full", "stock": stock_name}
_acct_left = _account_reject_cooldown_remaining_v2()
if _acct_left > 0:
return {"action": "SKIP", "reason": f"account_reject_cooldown({_acct_left/60:.0f}m)", "stock": stock_name}
_rc_left = _order_reject_cooldown_remaining_v2(stock_code)
if _rc_left > 0:
return {"action": "SKIP", "reason": f"order_reject_cooldown({_rc_left/60:.0f}m)", "stock": stock_name}
# [M3] 리스크 가드(MDD/밴/보유수/당일매도쿨다운)는 신규 매수만 차단한다
if _buy_blocked_reason is not None:
logger.info(
f"[TRADE] {stock_code}/{stock_name} 매수 차단: {_buy_blocked_reason} (청산 판정은 수행됨)"
)
return {"action": "SKIP", "reason": _buy_blocked_reason, "stock": stock_name}
logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}")
return await _execute_buy(
stock, db, holding, current_price, signals, score_val,
is_domestic, exchange_code, stock_code, stock_name,
)
logger.info(f"[TRADE] ⏸️ HOLD: {stock_code}/{stock_name} (buy={should_buy}, sell={should_sell}, has_rule={has_rule})")
# [2026-09-27 H1-A] BLOCK(과열·이격) 사유는 조기 return 제거 후에도 그대로 노출 —
# 호출측 로깅·집계 호환을 위해 기존 reason 문자열을 유지한다.
if _block_overheat_reason is not None:
return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason}
return {"action": "HOLD", "stock": stock_name, "reason": "no_buy_signal(진입조건 미충족)"}
except ValueError as e:
logger.error(f"[TRADE_VALIDATION] {stock.get('stock_code')}: {e}")
return {"action": "ERROR", "error": str(e)}
except Exception as e:
logger.error(f"[TRADE_ERROR] {stock.get('stock_code')}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "ERROR", "error": str(e)}