feat: MCP 서버 리스크 검증 파이프라인 및 청산 상태머신 개선
- risk_validator.py: 가격/포지션/MDD/레버리지 5단계 검증 - auto_trade_claude.py: 주문 전 리스크 검증 파이프라인 통합 - trade_monitor.py: ATR 기반 익절/트레일링 스탑, API 기반 상태 전환 (active → pending_sell → closed), 2R 수익 시 50% 분할 매도 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import os
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
|
||||
# backend 경로를 sys.path에 추가하여 서비스 모듈 임포트
|
||||
import sys
|
||||
@@ -27,6 +28,155 @@ AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=F
|
||||
|
||||
ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json')
|
||||
MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인
|
||||
BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000')
|
||||
|
||||
async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool:
|
||||
"""
|
||||
CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트.
|
||||
|
||||
활성 거래의 상태를 API를 통해 업데이트한다.
|
||||
이는 db_worker를 우회하지 않으면서도 여러 시스템이 동시에 접근할 수 있게 한다.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
new_status: 목표 상태 ('active', 'pending_sell', 'closed')
|
||||
reason: 상태 변경 이유
|
||||
|
||||
Returns:
|
||||
성공 여부
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status"
|
||||
payload = {
|
||||
"status": new_status,
|
||||
"reason": reason,
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
if resp.status == 200:
|
||||
logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}")
|
||||
return True
|
||||
else:
|
||||
error_text = await resp.text()
|
||||
logging.error(f"❌ [{stock_code}] 상태 변경 API 실패 (HTTP {resp.status}): {error_text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"❌ [{stock_code}] 상태 변경 API 호출 중 예외: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def calculate_atr_and_highest(db: AsyncSession, stock_code: str) -> tuple[float, float]:
|
||||
"""
|
||||
최근 14개 캔들에서 ATR과 최고가를 계산한다.
|
||||
|
||||
Args:
|
||||
db: 비동기 DB 세션
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
(atr, highest_since_entry) 튜플
|
||||
"""
|
||||
try:
|
||||
# StockDailyPrice에서 최근 14개 캔들 조회
|
||||
from app.models.models import StockDailyPrice
|
||||
from sqlalchemy import desc
|
||||
|
||||
result = await db.execute(
|
||||
select(StockDailyPrice)
|
||||
.where(StockDailyPrice.stock_code == stock_code)
|
||||
.order_by(desc(StockDailyPrice.trade_date))
|
||||
.limit(14)
|
||||
)
|
||||
prices = list(reversed(result.scalars().all()))
|
||||
|
||||
if len(prices) < 2:
|
||||
logging.warning(f"[{stock_code}] ATR 계산을 위한 충분한 가격 데이터 없음")
|
||||
return 0.0, 0.0
|
||||
|
||||
# ATR 계산 (True Range 평균)
|
||||
tr_values = []
|
||||
for i in range(1, len(prices)):
|
||||
high = prices[i].high_price
|
||||
low = prices[i].low_price
|
||||
prev_close = prices[i - 1].close_price
|
||||
|
||||
tr = max(
|
||||
high - low,
|
||||
abs(high - prev_close),
|
||||
abs(low - prev_close)
|
||||
)
|
||||
tr_values.append(tr)
|
||||
|
||||
atr = sum(tr_values) / len(tr_values) if tr_values else 0.0
|
||||
|
||||
# 최고가 계산 (진입 이후)
|
||||
highest = max(p.high_price for p in prices) if prices else 0.0
|
||||
|
||||
return float(atr), float(highest)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{stock_code}] ATR/최고가 계산 중 예외: {e}")
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
async def check_partial_takeprofit_and_trailing_stop(
|
||||
stock_code: str,
|
||||
current_price: float,
|
||||
entry_price: float,
|
||||
remaining_pct: float,
|
||||
highest_since_entry: float,
|
||||
atr: float
|
||||
) -> tuple[bool, float, str]:
|
||||
"""
|
||||
CLAUDE.md Section 5.2: 부분 익절 및 트레일링 스탑 로직
|
||||
|
||||
- +2R 수익 (리스크 대비 2배): 50% 분할 매도
|
||||
- 나머지 50%: high - (1.5 * ATR) 트레일링 스탑
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
current_price: 현재가
|
||||
entry_price: 진입가
|
||||
remaining_pct: 남은 수량 비율 (기본 1.0)
|
||||
highest_since_entry: 진입 이후 최고가
|
||||
atr: ATR 값
|
||||
|
||||
Returns:
|
||||
(should_sell, sell_price, reason) 튜플
|
||||
"""
|
||||
if remaining_pct <= 0:
|
||||
return False, 0.0, ""
|
||||
|
||||
profit = current_price - entry_price
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# +2R 익절 조건 (처음 50% 분할 매도)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 평균 손절까지의 거리를 리스크로 정의 (기본값: 진입가의 6%)
|
||||
risk = entry_price * 0.06 # 기본 손절: -6%
|
||||
|
||||
if profit >= 2 * risk and remaining_pct == 1.0:
|
||||
# 첫 50%만 매도
|
||||
logging.info(f"[{stock_code}] +2R 익절 조건 충족 (수익: {profit}, 리스크: {risk})")
|
||||
return True, current_price, f"+2R 익절 (수익: {profit:.0f}원)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 트레일링 스탑 (나머지 50% 물량)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if remaining_pct < 1.0 and atr > 0: # 부분 매도 후 남은 물량
|
||||
trailing_stop_price = highest_since_entry - (1.5 * atr)
|
||||
|
||||
if current_price <= trailing_stop_price:
|
||||
logging.info(
|
||||
f"[{stock_code}] 트레일링 스탑 도달 "
|
||||
f"(현재: {current_price}, 스탑: {trailing_stop_price})"
|
||||
)
|
||||
return True, current_price, f"트레일링 스탑 (스탑가: {trailing_stop_price:.0f}원)"
|
||||
|
||||
return False, 0.0, ""
|
||||
|
||||
|
||||
async def sync_json_from_db(db: AsyncSession):
|
||||
"""DB의 active_trades 목록을 JSON 파일에 동기화(덮어쓰기)합니다."""
|
||||
@@ -60,12 +210,20 @@ async def sync_json_from_db(db: AsyncSession):
|
||||
async def check_and_execute_trades(db: AsyncSession):
|
||||
"""
|
||||
DB에서 활성화된 거래를 확인하고 손절/익절 조건에 따라 매도 주문을 실행합니다.
|
||||
|
||||
CLAUDE.md Section 3.2: 직접 DB commit 금지
|
||||
- 상태 변경은 API를 통해 처리 (db_worker 우회 방지)
|
||||
- 모든 state transition을 logging
|
||||
|
||||
CLAUDE.md Section 5.2: 부분 익절 및 트레일링 스탑
|
||||
- +2R: 50% 분할 매도 (pending_sell 상태)
|
||||
- 나머지 50%: trailing stop (high - 1.5*ATR)
|
||||
"""
|
||||
logging.info(" 거래 모니터링 시작 (DB 기반)...")
|
||||
|
||||
logging.info("거래 모니터링 시작 (DB 기반)...")
|
||||
|
||||
result = await db.execute(select(ActiveTrade).where(ActiveTrade.status == 'active'))
|
||||
active_trades = result.scalars().all()
|
||||
|
||||
|
||||
if not active_trades:
|
||||
logging.info("현재 활성화된 거래가 없습니다.")
|
||||
return
|
||||
@@ -76,9 +234,12 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
take_profit = float(trade.take_profit)
|
||||
quantity = trade.quantity
|
||||
entry_price = float(trade.entry_price)
|
||||
|
||||
remaining_pct = float(trade.remaining_pct) if trade.remaining_pct else 1.0
|
||||
highest_since_entry = float(trade.highest_since_entry) if trade.highest_since_entry else entry_price
|
||||
|
||||
should_sell = False
|
||||
sell_reason = ""
|
||||
sell_quantity = 0
|
||||
|
||||
try:
|
||||
# 현재가 조회
|
||||
@@ -88,7 +249,7 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
"FHKST01010100",
|
||||
params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}
|
||||
)
|
||||
|
||||
|
||||
if price_info and price_info.get('rt_cd') == '0':
|
||||
current_price_str = price_info.get('output', {}).get('stck_prpr')
|
||||
if not current_price_str:
|
||||
@@ -96,18 +257,52 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
continue
|
||||
|
||||
current_price = int(current_price_str)
|
||||
logging.info(f"[{stock_code}] 현재가: {current_price} (매수가: {entry_price}, 손절가: {stop_loss}, 목표가: {take_profit})")
|
||||
logging.info(
|
||||
f"[{stock_code}] 현재가: {current_price} (진입: {entry_price}, "
|
||||
f"손절: {stop_loss}, 목표: {take_profit}, 남은비율: {remaining_pct*100:.1f}%)"
|
||||
)
|
||||
|
||||
# 손절 조건 확인
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 1. 손절 조건 확인
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if stop_loss and current_price <= stop_loss:
|
||||
should_sell = True
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
|
||||
|
||||
# 익절 조건 확인
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 2. 기본 익절 조건 확인
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
elif take_profit and current_price >= take_profit:
|
||||
should_sell = True
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
else:
|
||||
atr, new_highest = await calculate_atr_and_highest(db, stock_code)
|
||||
|
||||
# highest_since_entry 업데이트
|
||||
if new_highest > highest_since_entry:
|
||||
highest_since_entry = new_highest
|
||||
|
||||
should_tp, tp_price, tp_reason = await check_partial_takeprofit_and_trailing_stop(
|
||||
stock_code, current_price, entry_price, remaining_pct,
|
||||
highest_since_entry, atr
|
||||
)
|
||||
|
||||
if should_tp:
|
||||
should_sell = True
|
||||
if remaining_pct == 1.0:
|
||||
# 첫 익절: 50% 분할 매도
|
||||
sell_quantity = int(quantity * 0.5)
|
||||
else:
|
||||
# 트레일링 스탑: 남은 물량 전부
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
sell_reason = tp_reason
|
||||
|
||||
else:
|
||||
logging.error(f"[{stock_code}] 현재가 조회 실패: {price_info.get('msg1', '알 수 없는 오류')}")
|
||||
continue
|
||||
@@ -116,30 +311,115 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
logging.error(f"[{stock_code}] 처리 중 예외 발생: {e}")
|
||||
continue
|
||||
|
||||
if should_sell:
|
||||
logging.warning(f"🚨 [{stock_code}] 매도 조건 충족: {sell_reason}")
|
||||
try:
|
||||
# 시장가 매도 주문 (price=None)
|
||||
sell_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type='SELL',
|
||||
quantity=quantity,
|
||||
price=None
|
||||
)
|
||||
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
|
||||
if should_sell and sell_quantity > 0:
|
||||
await _execute_sell_with_pending_state(
|
||||
stock_code, quantity, sell_quantity, entry_price, sell_reason
|
||||
)
|
||||
|
||||
if sell_result and sell_result.get('rt_cd') == '0':
|
||||
logging.info(f"✅ [{stock_code}] 매도 주문 성공. DB 상태를 'closed'로 변경합니다.")
|
||||
trade.status = 'closed'
|
||||
trade.updated_at = datetime.utcnow()
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
else:
|
||||
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {sell_result.get('msg1', '알 수 없는 오류')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{stock_code}] 매도 주문 중 예외 발생: {e}")
|
||||
await db.rollback()
|
||||
async def _execute_sell_with_pending_state(
|
||||
stock_code: str,
|
||||
total_quantity: int,
|
||||
sell_quantity: int,
|
||||
entry_price: float,
|
||||
sell_reason: str
|
||||
) -> None:
|
||||
"""
|
||||
CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이
|
||||
|
||||
단계:
|
||||
1. 상태를 'pending_sell'로 변경 (즉시)
|
||||
2. 매도 주문 실행
|
||||
3. 주문 성공 시 'closed'로 변경 (1-5초 대기)
|
||||
4. 실패 시 'active'로 롤백
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
total_quantity: 보유 수량
|
||||
sell_quantity: 매도할 수량
|
||||
entry_price: 진입가
|
||||
sell_reason: 매도 이유
|
||||
"""
|
||||
try:
|
||||
logging.warning(
|
||||
f"🚨 [{stock_code}] 매도 조건 충족: {sell_reason} "
|
||||
f"(수량: {sell_quantity}/{total_quantity})"
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1: 상태를 'pending_sell'로 즉시 변경
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"pending_sell",
|
||||
f"매도 주문 대기 중: {sell_reason}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
logging.error(f"❌ [{stock_code}] pending_sell 상태 전이 실패")
|
||||
return
|
||||
|
||||
logging.info(f"⏳ [{stock_code}] 상태: active → pending_sell (매도 주문 대기)")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2: 시장가 매도 주문 실행
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
sell_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type='SELL',
|
||||
quantity=sell_quantity,
|
||||
price=None # 시장가
|
||||
)
|
||||
|
||||
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
|
||||
|
||||
if sell_result and sell_result.get('rt_cd') == '0':
|
||||
order_id = sell_result.get('output', {}).get('order_id', '')
|
||||
logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경
|
||||
# ─────────────────────────────────────────────────────────
|
||||
await asyncio.sleep(2) # 체결 대기
|
||||
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"closed",
|
||||
f"매도 완료: {sell_reason} (주문ID: {order_id})"
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
|
||||
else:
|
||||
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
|
||||
else:
|
||||
error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음"
|
||||
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Step 4: 주문 실패 시 'active'로 롤백
|
||||
# ─────────────────────────────────────────────────────────
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"active",
|
||||
f"매도 주문 실패로 복구: {error_msg}"
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(f"🔄 [{stock_code}] 상태 롤백: pending_sell → active")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{stock_code}] 매도 실행 중 예외 발생: {e}", exc_info=True)
|
||||
|
||||
# 예외 발생 시에도 'active'로 복구
|
||||
try:
|
||||
await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"active",
|
||||
f"예외로 인한 롤백: {str(e)}"
|
||||
)
|
||||
except Exception as rollback_exc:
|
||||
logging.error(f"[{stock_code}] 롤백 중 예외: {rollback_exc}")
|
||||
|
||||
async def main():
|
||||
"""메인 실행 루프"""
|
||||
|
||||
Reference in New Issue
Block a user