feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API

- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:37:08 +09:00
parent b1da208fd5
commit 028a1c352b
39 changed files with 1073 additions and 694 deletions

View File

@@ -30,7 +30,14 @@ 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:
# 백엔드 require_api_key와 동일한 소스 (app/core/security.py: API_KEY 우선, 없으면 SECRET_KEY 폴백)
API_KEY = settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
_API_HEADERS = {"X-API-Key": API_KEY}
async def update_active_trade_state_via_api(
stock_code: str, new_status: str, reason: str = "", remaining_pct: float = None
) -> bool:
"""
CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트.
@@ -41,20 +48,25 @@ async def update_active_trade_state_via_api(stock_code: str, new_status: str, re
stock_code: 종목 코드
new_status: 목표 상태 ('active', 'pending_sell', 'closed')
reason: 상태 변경 이유
remaining_pct: 부분 매도 후 잔여 비율 (제공 시 ActiveTrade.remaining_pct 갱신)
Returns:
성공 여부
"""
try:
async with aiohttp.ClientSession() as session:
url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status"
url = f"{BACKEND_API_BASE}/api/v1/portfolio/active-trades/{stock_code}/status"
payload = {
"status": new_status,
"reason": reason,
"updated_at": datetime.utcnow().isoformat()
}
if remaining_pct is not None:
payload["remaining_pct"] = remaining_pct
async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
async with session.patch(
url, json=payload, headers=_API_HEADERS, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}")
return True
@@ -240,6 +252,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = False
sell_reason = ""
sell_quantity = 0
new_remaining_pct = 0.0 # 매도 후 잔여 비율 (0.0 = 전량 매도 → closed)
try:
# 현재가 조회
@@ -269,6 +282,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 2. 기본 익절 조건 확인
@@ -277,6 +291,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2)
@@ -296,11 +311,13 @@ async def check_and_execute_trades(db: AsyncSession):
if should_tp:
should_sell = True
if remaining_pct == 1.0:
# 첫 익절: 50% 분할 매도
# 첫 익절: 50% 분할 매도 — 잔량 50%는 'active'로 유지 (CLAUDE.md 5.2)
sell_quantity = int(quantity * 0.5)
new_remaining_pct = remaining_pct * 0.5
else:
# 트레일링 스탑: 남은 물량 전부
# 트레일링 스탑: 남은 물량 전부 → closed
sell_quantity = int(quantity * remaining_pct)
new_remaining_pct = 0.0
sell_reason = tp_reason
else:
@@ -313,7 +330,7 @@ async def check_and_execute_trades(db: AsyncSession):
if should_sell and sell_quantity > 0:
await _execute_sell_with_pending_state(
stock_code, quantity, sell_quantity, entry_price, sell_reason
stock_code, quantity, sell_quantity, entry_price, sell_reason, new_remaining_pct
)
@@ -322,7 +339,8 @@ async def _execute_sell_with_pending_state(
total_quantity: int,
sell_quantity: int,
entry_price: float,
sell_reason: str
sell_reason: str,
new_remaining_pct: float = 0.0
) -> None:
"""
CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이
@@ -330,7 +348,9 @@ async def _execute_sell_with_pending_state(
단계:
1. 상태를 'pending_sell'로 변경 (즉시)
2. 매도 주문 실행
3. 주문 성공 시 'closed'로 변경 (1-5초 대기)
3. 주문 성공 시 상태 전이 (1-5초 대기)
- new_remaining_pct > 0 (부분 매도): 'active' 유지 + remaining_pct 갱신
- new_remaining_pct == 0 (전량 매도): 'closed'로 전이
4. 실패 시 'active'로 롤백
Args:
@@ -339,6 +359,7 @@ async def _execute_sell_with_pending_state(
sell_quantity: 매도할 수량
entry_price: 진입가
sell_reason: 매도 이유
new_remaining_pct: 매도 후 잔여 비율 (0.0이면 전량 매도 → closed)
"""
try:
logging.warning(
@@ -374,24 +395,41 @@ async def _execute_sell_with_pending_state(
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', '')
order_id = sell_result.get('output', {}).get('ODNO', '')
logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})")
# ─────────────────────────────────────────────────────────
# Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경
# Step 3: 1-5초 대기 후 체결 확인 및 상태 전이
# CLAUDE.md 5.2: 부분 매도(+2R 50%)는 'active' 유지 + remaining_pct 갱신,
# 전량 매도만 '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")
if new_remaining_pct > 0:
success = await update_active_trade_state_via_api(
stock_code,
"active",
f"부분 매도 완료: {sell_reason} (주문ID: {order_id})",
remaining_pct=new_remaining_pct
)
if success:
logging.info(
f"✅ [{stock_code}] 상태: pending_sell → active "
f"(잔여비율: {new_remaining_pct*100:.1f}%)"
)
else:
logging.warning(f"⚠️ [{stock_code}] 부분매도 후 active 전이 실패 (order_id: {order_id})")
else:
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
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}")