Files
auto-stock/backend/app/services/trade_executor.py
shuim a0973812fb fix: 매수 차단·백테스트 왜곡 유발한 버그 5종 수정
수개월간 매수 0건 + 백테스트가 손실로 보이던 근본 원인들을 수정.

- ADX 발산(mean_reversion_strategy, run_backtest): Wilder 평활에 /period
  누락으로 ADX가 100 초과 발산 → 실서비스 SIDEWAYS 게이트(ADX<20) 영구
  무력화. 공식 복원(표준 Wilder와 대수적으로 동일 검증).
- 백테스트 워밍업(run_backtest): 국면판정 220봉을 start_date부터 세서
  14개월 미만 백테스트의 ~90%가 강제 BEAR → 가짜 손실. start_date 340일
  전부터 프리로드하고 성과는 sim_start_date부터만 집계. CAGR 분모도 워밍업
  제외한 실제 거래일로 수정. → 최근1년 -0.33%→+6.95%, 가산성 회복.
- ML HOLD 게이트(trade_executor): 신뢰도 무관 매수 차단 → 데이터 부족으로
  ML이 실패(conf=0)한 HOLD까지 모든 매수를 막던 것을 conf>=0.6 조건부로.
- 빈 Redis 추천 캐시(swing/scheduler): 빈 캐시를 '히트'로 처리해 DB 폴백을
  건너뛰던 것을 recommendations_by_rule 비면 DB 폴백하도록.
- alembic 로깅(env.py, main.py): lifespan의 alembic upgrade가 fileConfig로
  앱 로거를 꺼 startup 이후 로그 전멸 → 스킵(스키마는 create_all 담당).

검증: code-verifier 통과(실전 경로 CONFIRMED 버그 없음), walk-forward 6/9구간
양(+)·과적합 아님(조건부 견고). 서바이버십 편향은 별도 이슈로 검증 진행 중.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
2026-09-16 10:03:56 +09:00

1564 lines
77 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 timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
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,
)
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.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,
)
logger = logging.getLogger(__name__)
# ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
_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
async def _acquire_order_lock(stock_code: str, order_type: str) -> bool:
"""Redis SETNX 기반 분산락 획득. 성공 시 True.
[동작] Redis SET NX(NOT EXISTS) + EX(TTL 60초)로 원자적 락 설정.
- 동일 종목·방향의 주문이 이미 처리 중이면 False → 호출측에서 SKIP.
- Redis 미연결 시 True(락 없이 진행) — 이 경우 세마포어가 2차 방어.
"""
try:
from app.core.redis import get_redis
client = get_redis()
if not client:
return True # Redis 미연결 → 락 없이 진행 (세마포어가 대신 보호)
key = f"order_lock:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
return result is True # None이면 이미 키 존재 → 락 실패
except Exception as e:
logger.debug(f"[order_lock] 획득 오류 (패스): {e}")
return True
async def _release_order_lock(stock_code: str, order_type: str) -> None:
"""Redis 분산락 해제.
[동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제.
"""
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회) ───────────────────
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"(성공) 또는 "1"(PENDING) → 즉시 반환 (재시도 불필요)
3. 그 외 오류: 1초→2초→4초 대기 후 최대 max_retries회 재시도
4. 최대 재시도 소진 시 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
)
# rt_cd "0"=성공, "1"=PENDING → 재시도 불필요
if result.get("rt_cd") in ("0", "1"):
return result
# rt_cd 오류 → 재시도 대상
last_result = 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:
# 네트워크 타임아웃 등 예외 → 재시도 대상
last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None}
if attempt < max_retries - 1:
wait = 1 * (2 ** attempt)
logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}")
await asyncio.sleep(wait)
else:
logger.error(f"[ORDER_MAX_RETRY] {stock_code} {order_type} 최대 재시도 초과: {type(e).__name__}: {e}")
return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": 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", "msg1": "order_lock_busy"}
try:
async with _get_order_semaphore(stock_code):
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", "msg1": "no_response"}
except Exception as e:
logger.error(f"[SAFE_ORDER] {stock_code} {order_type} 실패: {e}")
return {"rt_cd": "-1", "msg1": str(e)}
finally:
await _release_order_lock(stock_code, order_type)
# ── 미체결 주문 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.debug(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()
# ── 지정가 조정 — 체결률 향상 ──────────────────────────────────────────────────
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]
wins = [r for r in rates if r > 0] # 수익 거래 목록
losses = [abs(r) for r in rates if r < 0] # 손실 거래 절대값 목록
if not wins or not losses:
return 0.0 # 전승 또는 전패 → Kelly 계산 불가
p = len(wins) / len(rates) # 승률
b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) # Payoff Ratio
kelly_f = (p * b - (1 - p)) / b # Kelly 공식
half_kelly = max(0.0, kelly_f / 2) # Half-Kelly (보수적)
clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용
return min(total_assets * clamped, available_cash, total_assets * max_per_position)
except Exception as e:
logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}")
return 0.0
# ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ───────────────────────────────
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:
"""주문 후 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"
- 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문
5. 포트폴리오 동기화 트리거
"""
await asyncio.sleep(delay) # KIS 서버 체결 처리 대기
if not order_id:
# order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행
await _delayed_sync_portfolio(0)
return
try:
# KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R)
# [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응
filled_qty = 0
filled_price = 0.0
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))
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
if filled_qty > 0:
break # 체결 확인됨
if poll < max_polls - 1:
logger.debug(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 "
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}"
)
# ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
slippage_pct = 0.0
if 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}%"
)
# ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ───
if filled_qty > 0:
partial = filled_qty < ordered_quantity # 부분 체결 여부
update_fields = {
"executed_quantity": filled_qty,
"executed_price": Decimal(str(filled_price)),
"status": "PARTIAL" if partial else "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)
# ── 부분 체결 → 미체결 수량 재주문 ──────────────────────
if partial:
remain = ordered_quantity - filled_qty
logger.warning(
f"[PARTIAL_FILL] {stock_code} {order_type} "
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
)
# 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지)
asyncio.create_task(
_reorder_unfilled(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
quantity=remain,
price=order_price,
is_domestic=is_domestic,
exchange_code=exchange_code,
)
)
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}")
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"}
# ── 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 직접) ─────
cached = _get_cached_account()
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:
pass
# 2차: 캐시 미스 → KIS API 직접 조회 (잔고 0이면 미초기화로 판단)
if available_cash <= 0 and total_assets <= 0:
try:
balance = await korea_investment_api.get_balance()
output2 = balance.get("output2", [{}])
if output2:
b = output2[0] if isinstance(output2, list) else output2
available_cash = float(b.get("dnca_tot_amt", 0))
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}")
if available_cash > 0 or total_assets > 0:
_set_account_cache(available_cash, total_assets)
# [이슈1 수정] TradeHistory.created_at은 UTC 저장(datetime.utcnow)이므로
# 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
# [이슈1 수정] created_at은 UTC이므로 현재 시각도 UTC로 비교
from datetime import datetime as _dt_utc2
if recent_buys and (_dt_utc2.utcnow() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60:
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# ── 6.5. 상관관계 필터 (CLAUDE.md 섹션 5.4) ──────────────────────────────────
# 섹터 집중도 + 포트폴리오 상관계수 필터
current_holdings = [] # 기본값 (상관관계 필터 실패 시에도 레버리지 한도 체크 가능)
try:
# 현재 포트폴리오 보유 종목 조회
portfolio_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
current_holdings = 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
)
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}")
except Exception as e:
logger.error(f"[CORRELATION_FILTER_ERROR] {stock_code}: {type(e).__name__}: {e}")
# 필터 오류 시 진입 거부하지 않음 (데이터 부족 시 정상 동작)
pass
# ── 6.6. 레버리지/인버스 ETF 20% 한도 체크 (CLAUDE.md 섹션 5.1) ────────────
_leverage_keywords = get_leverage_check_keywords()
if is_leverage_etf(stock_name, _leverage_keywords) and total_assets > 0:
# 현재 보유 중인 레버리지/인버스 ETF 총 평가액 계산
try:
_lev_total = 0.0
for h in current_holdings:
h_name = getattr(h, "stock_name", "") or ""
if is_leverage_etf(h_name, _leverage_keywords):
_lev_total += float(getattr(h, "quantity", 0) or 0) * float(getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0)
_lev_ratio = _lev_total / total_assets
if _lev_ratio >= settings.LEVERAGE_ETF_MAX_RATIO:
logger.warning(
f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: "
f"레버리지 ETF 비중 {_lev_ratio*100:.1f}% >= {settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 한도 초과"
)
return {"action": "HOLD", "stock": stock_name, "reason": "leverage_etf_limit_exceeded"}
except Exception as e:
logger.debug(f"[LEVERAGE_LIMIT] 한도 체크 오류 ({stock_code}): {e}")
# ── 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=0, Defensive=3% 등)
_adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4%
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_adaptive_position_cap = _adp.position_size
# Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.5)
if _adp.trading_mode == "crisis" and _adp.max_daily_trades <= 0:
return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name}
except Exception:
pass
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}
# ── 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),
)
# CLAUDE.md 5.1: 패턴 조정 후에도 종목당 4% 상한 재적용
max_by_position = total_assets * settings.MAX_POSITION_SIZE
invest_amount = min(invest_amount, max_by_position, available_cash)
except Exception as _pm_err:
logger.debug(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (무시): {_pm_err}")
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
try:
_position_multiplier = float(stock.get("_position_multiplier", 1.0) or 1.0)
except Exception:
_position_multiplier = 1.0
_position_multiplier = max(0.0, min(1.0, _position_multiplier))
try:
_news_position_scale = float(stock.get("_news_position_scale", 1.0) or 1.0)
except Exception:
_news_position_scale = 1.0
_news_position_scale = max(0.0, min(1.0, _news_position_scale))
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}"
)
# 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지)
quantity = int(Decimal(str(invest_amount)) / Decimal(str(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:
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
# 최소 매수 금액 가드: 50만원 미만이면 수수료 대비 비효율
_MIN_BUY_AMOUNT = 500_000
_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
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
success = order_result.get("rt_cd") == "0" # KIS 성공 코드
is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인)
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
# 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,
"strategy_name": 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:
pass
# 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:
pass
# 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:
pass
# 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,
)
)
elif is_pending:
# PENDING(주문접수) 상태이면 60초 후 미체결 자동 취소 시도
order_id_val = build_kis_order_id(order_result)
if order_id_val:
asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity))
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")
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 기반)
"""
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
total_qty = holding.quantity
quantity = max(1, int(total_qty * sell_pct)) # 최소 1주
if sell_pct >= 1.0:
quantity = total_qty # 전량매도 시 잔여분 없도록
_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
)
stop_loss = atr_stops["stop_loss"]
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. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
order_price = _adjust_order_price(current_price, "SELL", 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="SELL",
quantity=quantity,
price=order_price,
exchange_code=exchange_code,
)
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"
is_pending = order_result.get("rt_cd") == "1"
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
# 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 기록
if sell_pct >= 1.0:
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:
if sell_pct >= 1.0:
from app.services.db_worker import enqueue_close_active_trade
await enqueue_close_active_trade(stock_code, strategy_name=stock.get("strategy_type", "swing"))
else:
from app.services.db_worker import enqueue_active_trade_upsert
# [BUG FIX] 기존 remaining_pct에서 sell_pct를 차감해야 누적 분할매도가 올바르게 반영됨
# 예: 1차 50% 매도 후 remaining=0.5, 2차 50% 매도 시 remaining=0.0
remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0)
new_remaining = max(0.0, remaining_pct - sell_pct)
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"remaining_pct": new_remaining,
"highest_since_entry": current_price,
})
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] 분할매도: 잔여 수량이 있으면 캐시에서 제거하지 않음
remaining_qty = 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:
pass
# 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:
pass
# 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:
pass
# 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="SELL",
is_domestic=is_domestic,
exchange_code=exchange_code,
db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회
delay=3,
)
)
elif is_pending:
# PENDING 상태 → 60초 후 미체결 자동 취소 시도
order_id_val = build_kis_order_id(order_result)
if order_id_val:
asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity))
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")
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
try:
total_processed = 0
trade_results = []
# 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소)
market_regime = None
if settings.BEAR_MARKET_FILTER_ENABLED:
market_regime = await check_market_regime()
regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20
position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0
max_trades = min(20, regime_max_trades)
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합
# 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)),
)
for stock in sorted_stocks[:max_trades]:
if not isinstance(stock, dict):
logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock)
continue
try:
stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입
stock["_regime_str"] = (
market_regime.get("regime_200d", "bear") if market_regime else "bear"
)
result = await execute_auto_trade(stock, db)
total_processed += 1
if result["action"] in ["BUY", "SELL"]:
trade_results.append(result)
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
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 '?'}"
)
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}")
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/리츠 예외) ─────────────────────────
_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:
if current_price <= settings.MIN_PRICE_KRW and not _is_etf: # CLAUDE.md 5.4: "초과"
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
else:
if current_price <= settings.MIN_PRICE_USD and not _is_etf: # CLAUDE.md 5.4: "초과"
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
# ── MDD / 손실추적 / 보유수 ──────────────────────────────────────────
if not await mdd_manager.can_open_new_position():
# MDD -10% 이상 낙폭 → 신규 진입 중단
return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name}
if await loss_tracker.is_banned_safe(stock_code):
# 3회 연속 손실 종목 → 30거래일 진입 금지
return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name}
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
_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:
pass
if len(held_result.scalars().all()) >= _adaptive_max_holdings:
# 동시 보유 한도 초과 (적응형: max_holdings)
return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name}
# ── 당일 매도 후 재매수 쿨다운 ───────────────────────────────────────
# 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3)
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
recent_sell = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
TradeHistory.executed_at >= cutoff_time,
)
)
if recent_sell.first():
return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name}
# ── 현재 보유 조회 ────────────────────────────────────────────────────
result = await db.execute(
select(Portfolio).where(
Portfolio.stock_code == stock_code,
Portfolio.quantity > 0,
)
)
holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
# [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
_at_result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
)
)
_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)
except Exception as _at_err:
logger.debug(f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패: {_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:
weighted_score = (
score_val * settings.ML_ENSEMBLE_RULE_WEIGHT
+ float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT
)
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:
should_sell = True
elif (
ml_signal_trade == "HOLD"
and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7
):
should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제
except Exception as e:
logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}")
has_rule = stock.get("rule_id") is not None
if has_rule:
# 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호)
if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"}
# 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
else:
# 비규칙 종목: BLOCK 신호 있으면 과열 HOLD
if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
# 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 등)
remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0)
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,
)
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%)
if should_buy 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:
logger.debug(f"[NEWS_FILTER] {stock_code} 뉴스 스코어 조회 오류 (무시): {e}")
# 뉴스 포지션 배율을 stock dict에 전달 (_execute_buy에서 참조)
if _news_position_scale < 1.0:
stock["_news_position_scale"] = _news_position_scale
# ── 매매 실행 위임 ────────────────────────────────────────────────────
if should_buy:
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,
)
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] 분할매도 비율 전달
)
logger.info(f"[TRADE] ⏸️ HOLD: {stock_code}/{stock_name} (buy={should_buy}, sell={should_sell}, has_rule={has_rule})")
return {"action": "HOLD", "stock": stock_name}
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)}