코드리뷰 수정
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -19,14 +20,124 @@ from app.services.technical_indicators import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 동시 주문 1개로 제한 — 중복 주문 및 레이스 컨디션 방지
|
||||
_order_semaphore = asyncio.Semaphore(1)
|
||||
# ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
|
||||
_order_semaphores: dict = {}
|
||||
|
||||
|
||||
async def _mcp_validate_buy(stock: dict) -> tuple:
|
||||
# 전략 엔진(RSI/BB/MA/ATR 등)이 이미 신호를 검증하므로 AI 호출 불필요.
|
||||
# RISK_ASSESSMENT는 매매 직전 mcp.py에서 별도 수행.
|
||||
return True, "strategy_validated"
|
||||
def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore:
|
||||
"""종목별 세마포어 반환. 없으면 생성."""
|
||||
if stock_code not in _order_semaphores:
|
||||
_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."""
|
||||
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
|
||||
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 분산락 해제."""
|
||||
try:
|
||||
from app.core.redis import delete_key
|
||||
await delete_key(f"order_lock:{stock_code}:{order_type}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 주문 재시도 — 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. 시스템 오류에만 재시도."""
|
||||
last_result: dict = {}
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if is_domestic:
|
||||
result = await korea_investment_api.place_order(stock_code, order_type, quantity, price)
|
||||
else:
|
||||
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
|
||||
last_result = result
|
||||
if attempt < max_retries - 1:
|
||||
wait = 1 * (2 ** attempt) # 1s → 2s → 4s
|
||||
logger.warning(
|
||||
f"⚠️ 주문 실패 (rt_cd={result.get('rt_cd')}) "
|
||||
f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries}): {stock_code}"
|
||||
)
|
||||
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"⚠️ 주문 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {e}")
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
logger.error(f"❌ 주문 최대 재시도 초과: {stock_code} {order_type} — {e}")
|
||||
return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None}
|
||||
|
||||
|
||||
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
|
||||
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
|
||||
"""delay초 후에도 PENDING 상태면 자동 취소."""
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
result = await korea_investment_api.cancel_order(order_id, quantity)
|
||||
if result.get("rt_cd") == "0":
|
||||
logger.info(f"미체결 자동 취소 완료: {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} — {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 반환."""
|
||||
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:
|
||||
_account_cache["data"] = (available_cash, total_assets)
|
||||
_account_cache["ts"] = time.monotonic()
|
||||
|
||||
|
||||
# ── 지정가 ±0.5% 조정 — 체결률 향상 ──────────────────────────────────────────
|
||||
def _adjust_order_price(price: float, order_type: str) -> int:
|
||||
"""BUY +0.5% / SELL -0.5% 지정가 조정."""
|
||||
if order_type == "BUY":
|
||||
return int(price * 1.005)
|
||||
return int(price * 0.995)
|
||||
|
||||
|
||||
async def _execute_buy(
|
||||
@@ -43,7 +154,7 @@ async def _execute_buy(
|
||||
) -> dict:
|
||||
"""BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync."""
|
||||
|
||||
# ── 1. 3일 급등 체크 ─────────────────────────────────────────────────────
|
||||
# ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ───────────────────────────────
|
||||
try:
|
||||
if is_domestic:
|
||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
|
||||
@@ -64,7 +175,30 @@ async def _execute_buy(
|
||||
((current_price - price_3days_ago) / price_3days_ago) * 100
|
||||
if price_3days_ago > 0 else 0
|
||||
)
|
||||
if recent_gain > 10:
|
||||
# ATR 기반 동적 임계값: 최근 3일 평균 일간 변동폭 * 2, 최소 10%
|
||||
try:
|
||||
highs = (
|
||||
[float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]]
|
||||
if is_domestic
|
||||
else [float(item.get("high", 0)) for item in chart_data[-4:]]
|
||||
)
|
||||
lows = (
|
||||
[float(item.get("stck_lwpr", 0)) for item in chart_data[-4:]]
|
||||
if is_domestic
|
||||
else [float(item.get("low", 0)) for item in chart_data[-4:]]
|
||||
)
|
||||
daily_ranges = [
|
||||
abs(highs[i] - lows[i]) for i in range(len(closes))
|
||||
if closes[i] > 0 and highs[i] > 0 and lows[i] > 0
|
||||
]
|
||||
atr_pct = (
|
||||
(sum(daily_ranges) / len(daily_ranges) / price_3days_ago * 100)
|
||||
if daily_ranges and price_3days_ago > 0 else 0
|
||||
)
|
||||
surge_threshold = max(10.0, atr_pct * 2)
|
||||
except Exception:
|
||||
surge_threshold = 10.0
|
||||
if recent_gain > surge_threshold:
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
|
||||
except Exception as e:
|
||||
logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}")
|
||||
@@ -95,13 +229,18 @@ async def _execute_buy(
|
||||
except Exception:
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
|
||||
|
||||
# ── 3. 현금·자산 조회 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from app.api.endpoints.portfolio import portfolio_cache
|
||||
available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0
|
||||
total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0
|
||||
except Exception:
|
||||
available_cash, total_assets = 0.0, 0.0
|
||||
# ── 3. 현금·자산 조회 (30초 캐시 활용) ──────────────────────────────────
|
||||
cached = _get_cached_account()
|
||||
if cached:
|
||||
available_cash, total_assets = cached
|
||||
else:
|
||||
try:
|
||||
from app.api.endpoints.portfolio import portfolio_cache
|
||||
available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0
|
||||
total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0
|
||||
_set_account_cache(available_cash, total_assets)
|
||||
except Exception:
|
||||
available_cash, total_assets = 0.0, 0.0
|
||||
|
||||
daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -145,12 +284,7 @@ async def _execute_buy(
|
||||
if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60:
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
|
||||
|
||||
# ── 7. MCP 검증 ───────────────────────────────────────────────────────────
|
||||
mcp_ok, mcp_reason = await _mcp_validate_buy(stock)
|
||||
if not mcp_ok:
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason}
|
||||
|
||||
# ── 8. 포지션 사이징 ──────────────────────────────────────────────────────
|
||||
# ── 7. 포지션 사이징 ──────────────────────────────────────────────────────
|
||||
current_value = float(holding.quantity * current_price) if holding else 0.0
|
||||
regime_str = stock.get("_regime_str", "bear")
|
||||
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
|
||||
@@ -165,26 +299,32 @@ async def _execute_buy(
|
||||
if invest_amount <= 0 and total_assets <= 0:
|
||||
invest_amount = available_cash * 0.04
|
||||
|
||||
quantity = int(invest_amount / current_price)
|
||||
quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price)))
|
||||
if quantity <= 0:
|
||||
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
|
||||
|
||||
# ── 9. 주문 실행 (semaphore로 동시성 제어) ───────────────────────────────
|
||||
# ── 8. Redis 분산락 획득 ──────────────────────────────────────────────────
|
||||
if not await _acquire_order_lock(stock_code, "BUY"):
|
||||
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
|
||||
|
||||
# ── 9. 주문 실행 (종목별 세마포어 + 재시도) ───────────────────────────────
|
||||
try:
|
||||
async with _order_semaphore:
|
||||
if is_domestic:
|
||||
order_result = await korea_investment_api.place_order(
|
||||
stock_code, "BUY", quantity, int(current_price)
|
||||
)
|
||||
else:
|
||||
order_result = await korea_investment_api.place_overseas_order(
|
||||
stock_code, "BUY", quantity, float(current_price), exchange_code
|
||||
)
|
||||
order_price = _adjust_order_price(current_price, "BUY")
|
||||
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(current_price)) * quantity
|
||||
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
|
||||
success = order_result.get("rt_cd") == "0"
|
||||
status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED")
|
||||
is_pending = order_result.get("rt_cd") == "1"
|
||||
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
|
||||
|
||||
trade = TradeHistory(
|
||||
rule_id=stock.get("rule_id"),
|
||||
@@ -214,8 +354,12 @@ async def _execute_buy(
|
||||
portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price)
|
||||
except Exception:
|
||||
pass
|
||||
# KIS 체결 확인 후 포트폴리오 DB 동기화 (3초 딜레이)
|
||||
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3))
|
||||
elif is_pending:
|
||||
# PENDING 주문 → 1분 후 자동 취소
|
||||
order_id = order_result.get("odno")
|
||||
if order_id:
|
||||
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity))
|
||||
|
||||
return {
|
||||
"action": "BUY",
|
||||
@@ -227,6 +371,8 @@ async def _execute_buy(
|
||||
|
||||
except Exception as e:
|
||||
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
|
||||
finally:
|
||||
await _release_order_lock(stock_code, "BUY")
|
||||
|
||||
|
||||
async def _execute_sell(
|
||||
@@ -251,11 +397,28 @@ async def _execute_sell(
|
||||
take_profit = stock.get("take_profit", 3.0)
|
||||
stop_loss = stock.get("stop_loss", -1.5)
|
||||
|
||||
if not (profit_loss_rate >= take_profit or profit_loss_rate <= stop_loss or True):
|
||||
return {"action": "HOLD", "stock": stock_name}
|
||||
# ATR 기반 동적 손절/트레일링 스탑 조정
|
||||
atr = float(stock.get("atr", 0) or 0)
|
||||
_avg_price = float(holding.average_price or 0)
|
||||
exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%"
|
||||
if atr > 0 and _avg_price > 0:
|
||||
# ATR 손절: 진입가 - 2.5*ATR (CLAUDE.md 규칙)
|
||||
atr_stop_price = _avg_price - 2.5 * atr
|
||||
atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100
|
||||
stop_loss = max(stop_loss, atr_stop_pct) # 더 보수적인 값 사용
|
||||
|
||||
# 트레일링 스탑: 고점 - 1.5*ATR (CLAUDE.md 규칙)
|
||||
high_price = float(stock.get("high_price") or current_price)
|
||||
trailing_stop_price = high_price - 1.5 * atr
|
||||
if current_price <= trailing_stop_price and profit_loss_rate > 0:
|
||||
exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f} | {exit_reason_detail}"
|
||||
|
||||
# Redis 분산락 획득
|
||||
if not await _acquire_order_lock(stock_code, "SELL"):
|
||||
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
|
||||
|
||||
try:
|
||||
# ── 1. 중복 주문 체크 — 주문 전에 먼저 확인 ────────────────────────
|
||||
# ── 1. 중복 주문 체크 (DB + Redis 락 이중 방어) ──────────────────────
|
||||
recent_cutoff = _get_kst_now() - timedelta(seconds=10)
|
||||
duplicate_check = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
@@ -267,21 +430,23 @@ async def _execute_sell(
|
||||
if duplicate_check.scalar_one_or_none():
|
||||
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
|
||||
|
||||
# ── 2. 주문 실행 (semaphore로 동시성 제어) ──────────────────────────
|
||||
async with _order_semaphore:
|
||||
if is_domestic:
|
||||
order_result = await korea_investment_api.place_order(
|
||||
stock_code, "SELL", quantity, int(current_price)
|
||||
)
|
||||
else:
|
||||
order_result = await korea_investment_api.place_overseas_order(
|
||||
stock_code, "SELL", quantity, float(current_price), exchange_code
|
||||
)
|
||||
# ── 2. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
|
||||
order_price = _adjust_order_price(current_price, "SELL")
|
||||
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
|
||||
profit_loss = (current_price - float(holding.average_price or current_price)) * quantity
|
||||
success = order_result.get("rt_cd") == "0"
|
||||
status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED")
|
||||
is_pending = order_result.get("rt_cd") == "1"
|
||||
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
|
||||
|
||||
trade = TradeHistory(
|
||||
rule_id=stock.get("rule_id"),
|
||||
@@ -295,7 +460,7 @@ async def _execute_sell(
|
||||
total_amount=total_amount,
|
||||
status=status,
|
||||
order_id=order_result.get("odno"),
|
||||
message=f"자동매도: 손익률 {profit_loss_rate:.2f}%",
|
||||
message=f"자동매도: {exit_reason_detail}",
|
||||
exit_reason=f"손익: {profit_loss:,.0f}원",
|
||||
signals=signals,
|
||||
net_profit_loss=Decimal(str(profit_loss)) if success else None,
|
||||
@@ -317,8 +482,11 @@ async def _execute_sell(
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
# KIS 체결 확인 후 포트폴리오 DB 동기화
|
||||
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3))
|
||||
elif is_pending:
|
||||
order_id = order_result.get("odno")
|
||||
if order_id:
|
||||
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity))
|
||||
|
||||
return {
|
||||
"action": "SELL",
|
||||
@@ -331,6 +499,8 @@ async def _execute_sell(
|
||||
|
||||
except Exception as e:
|
||||
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):
|
||||
@@ -562,6 +732,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
if not has_strong_signal and (matched_count < 2 or score_val < 2):
|
||||
should_buy = False
|
||||
|
||||
# ── ATR 기반 강제 손절 (신호 없어도 발동) ────────────────────────────
|
||||
if holding and not should_sell:
|
||||
atr = float(stock.get("atr", 0) or 0)
|
||||
avg_price = float(holding.average_price or 0)
|
||||
if atr > 0 and avg_price > 0:
|
||||
atr_stop_price = avg_price - 2.5 * atr
|
||||
if current_price <= atr_stop_price:
|
||||
should_sell = True
|
||||
signals = list(signals) + [f"ATR 손절: {current_price:.0f} ≤ {atr_stop_price:.0f}"]
|
||||
|
||||
# ── 매매 실행 위임 ────────────────────────────────────────────────────
|
||||
if should_buy:
|
||||
return await _execute_buy(
|
||||
|
||||
Reference in New Issue
Block a user