Files
auto-stock/backend/app/services/trade_executor.py

632 lines
28 KiB
Python

import asyncio
import logging
import httpx
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
from app.services.technical_indicators import (
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma
)
logger = logging.getLogger(__name__)
# 동시 주문 1개로 제한 — 중복 주문 및 레이스 컨디션 방지
_order_semaphore = asyncio.Semaphore(1)
async def _mcp_validate_buy(stock: dict) -> tuple:
if not settings.MCP_VALIDATION_ENABLED:
return True, "mcp_disabled"
try:
payload = {
"analysis_type": "RULE_VALIDATION",
"data": {
"rule": {
"id": stock.get("rule_id"),
"name": stock.get("rule_name", "auto_trade"),
},
"market_data": {
"stock_code": stock.get("stock_code"),
"stock_name": stock.get("stock_name"),
"current_price": stock.get("current_price"),
"signals": stock.get("signals", []),
"score": stock.get("score"),
"ml_signal": stock.get("ml_signal"),
"ml_confidence": stock.get("ml_confidence"),
"ma20": stock.get("ma20"),
"ma60": stock.get("ma60"),
}
}
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json=payload
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
decision = result.get("decision", "HOLD")
confidence = float(result.get("confidence", 0.0))
if decision == "BUY" and confidence >= settings.MCP_CONFIDENCE_THRESHOLD:
return True, f"mcp_approved(conf={confidence:.2f})"
else:
return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})"
except httpx.HTTPStatusError as e:
if e.response.status_code == 500:
logger.warning(f"⚠️ MCP 서버 500 에러 — 룰 기반으로 우회: {e}")
return True, "mcp_500_fallback_to_rules"
logger.warning(f"⚠️ MCP 검증 실패 — 매매 차단: {e}")
return False, "mcp_unavailable"
except Exception as e:
logger.warning(f"⚠️ MCP 검증 실패 — 매매 차단: {e}")
return False, "mcp_unavailable"
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 기록 → sync."""
# ── 1. 3일 급등 체크 ─────────────────────────────────────────────────────
try:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
chart_data = chart_resp.get("output2", [])
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 4:
closes = (
[float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
if is_domestic
else [float(item.get("clos", 0)) for item in chart_data[-4:]]
)
if len(closes) >= 4:
price_3days_ago = closes[0]
recent_gain = (
((current_price - price_3days_ago) / price_3days_ago) * 100
if price_3days_ago > 0 else 0
)
if recent_gain > 10:
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
except Exception as e:
logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}")
# ── 2. 트렌드 필터 ────────────────────────────────────────────────────────
if TREND_FILTER_ENABLED:
try:
ma20 = stock.get("ma20")
ma60 = stock.get("ma60")
if ma20 is None or ma60 is None:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])]
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])]
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
ma20 = _ma(closes_local, TREND_MA_SHORT)
ma60 = _ma(closes_local, TREND_MA_LONG)
if not (
ma20 and ma60
and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT)
and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT)
):
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"}
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
daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
# ── 4. 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. 일 매수 횟수 제한 ─────────────────────────────────────────────────
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()) >= 2:
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
# ── 6. 쿨다운 체크 ────────────────────────────────────────────────────────
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30)
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()
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. 포지션 사이징 ──────────────────────────────────────────────────────
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
invest_amount = calculate_position_size_v2(
total_assets=total_assets,
available_cash=available_cash,
current_strategy_allocation=current_value,
regime=regime,
rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
)
if invest_amount <= 0 and total_assets <= 0:
invest_amount = available_cash * 0.04
quantity = int(invest_amount / current_price)
if quantity <= 0:
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
# ── 9. 주문 실행 (semaphore로 동시성 제어) ───────────────────────────────
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
)
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")
trade = TradeHistory(
rule_id=stock.get("rule_id"),
stock_code=stock_code,
stock_name=stock_name,
order_type="BUY",
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=order_result.get("odno"),
message=f"자동매수: {'추가매수' if holding else '신규'} | {' | '.join(signals[:2])}",
entry_reason=entry_reason,
signals=signals,
created_at=_get_kst_now(),
executed_at=_get_kst_now() if success else None,
)
db.add(trade)
await db.commit()
if success:
# 즉시 캐시 반영 (UI 실시간성)
try:
from app.api.endpoints.portfolio import portfolio_cache
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))
return {
"action": "BUY",
"stock": stock_name,
"quantity": quantity,
"price": current_price,
"success": success,
}
except Exception as e:
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
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,
) -> dict:
"""SELL 주문 실행 로직 — 중복체크 선행 → semaphore → 주문 → TradeHistory 기록 → sync."""
quantity = holding.quantity
_avg = float(holding.average_price or 0)
profit_loss_rate = (
((current_price - _avg) / _avg) * 100 if _avg > 0
else float(holding.profit_loss_rate or 0)
)
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}
try:
# ── 1. 중복 주문 체크 — 주문 전에 먼저 확인 ────────────────────────
recent_cutoff = _get_kst_now() - timedelta(seconds=10)
duplicate_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.created_at >= recent_cutoff,
)
)
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
)
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")
trade = TradeHistory(
rule_id=stock.get("rule_id"),
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=order_result.get("odno"),
message=f"자동매도: 손익률 {profit_loss_rate:.2f}%",
exit_reason=f"손익: {profit_loss:,.0f}원",
signals=signals,
net_profit_loss=Decimal(str(profit_loss)) if success else None,
profit_loss_rate=Decimal(str(profit_loss_rate)) if success else None,
created_at=_get_kst_now(),
executed_at=_get_kst_now() if success else None,
)
db.add(trade)
await db.commit()
if success:
# 즉시 캐시 반영
try:
from app.api.endpoints.portfolio import portfolio_cache
avg = float(holding.average_price or current_price)
portfolio_cache.apply_sell(stock_code, quantity, current_price, avg)
portfolio_cache.holdings = [
h for h in portfolio_cache.holdings if h.stock_code != stock_code
]
except Exception:
pass
# KIS 체결 확인 후 포트폴리오 DB 동기화
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3))
return {
"action": "SELL",
"stock": stock_name,
"quantity": quantity,
"price": current_price,
"profit_loss": profit_loss_rate,
"success": success,
}
except Exception as e:
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행."""
if not isinstance(recommendations_response, dict):
logger.error("recommendations_response is not a dict: %r", recommendations_response)
return
try:
total_processed = 0
trade_results = []
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()}
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
unique_stocks: dict = {}
for rule_group in rec_groups:
if not isinstance(rule_group, dict):
logger.warning("Invalid rule_group: %r", rule_group)
continue
stocks_list = rule_group.get("stocks")
if not isinstance(stocks_list, list):
logger.warning("Invalid stocks list: %r", stocks_list)
continue
for stock in stocks_list:
if not isinstance(stock, dict):
logger.warning("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
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("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)
except Exception as e:
logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}")
continue
except Exception as e:
logger.error(
f"❌ Background auto-trade error: {e} — "
f"recommendations_response={recommendations_response!r}"
)
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"Background portfolio sync failed: {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"Sync execution failed: {inner_e}")
except Exception as e:
logger.error(f"Delayed portfolio sync failed: {e}")
async def execute_auto_trade(stock: dict, db: AsyncSession):
"""종목 단건 매매 판단 및 실행.
공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후
_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"}
is_domestic = stock_code.isdigit() and len(stock_code) == 6
exchange_code = stock.get("exchange_code", "NASD")
# ── 가격 필터 ─────────────────────────────────────────────────────────
if is_domestic:
if current_price < settings.MIN_PRICE_KRW:
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
else:
if current_price < settings.MIN_PRICE_USD:
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
# ── MDD / 손실추적 / 보유수 ──────────────────────────────────────────
if not await mdd_manager.can_open_new_position():
return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name}
if loss_tracker.is_banned(stock_code):
return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name}
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
if len(held_result.scalars().all()) >= settings.MAX_HOLDINGS_COUNT:
return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name}
# ── 당일 매도 후 재매수 쿨다운 ───────────────────────────────────────
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 == "EXECUTED",
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()
# ── 신호 분석 ─────────────────────────────────────────────────────────
text_signals = " ".join(signals)
try:
score_val = float(stock.get("score", 0) or 0)
except Exception:
score_val = 0.0
buy_keywords = [
"정배열 눌림목 매수", "눌림목 진입 매수타점", "RSI 바닥 탈출 반등",
"RSI 안정 상승", "스토캐스틱 과매도", "5일선 > 20일선", "20일선 > 60일선",
"골든크로스", "볼린저 하한선 터치", "횡보장 — 평균회귀 유리", "거래량 증가와 상승",
]
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
sell_keywords = [
"역배열 하락 매도타점", "RSI 과매수", "스토캐스틱 과매수", "데드크로스",
"이동평균 역배열", "볼린저 상한선 돌파", "손절라인 이탈",
]
should_sell = any(k in text_signals for k in sell_keywords)
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:
if (
ml_signal_trade == "BUY"
and ml_score_trade >= 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 (ml_confidence_trade or 0) < settings.ML_ENSEMBLE_CONFIDENCE_MIN:
should_buy = False
has_rule = stock.get("rule_id") is not None
if has_rule:
if "이격도 과열" in text_signals or "매수금지" in text_signals:
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"}
if "급등 종목 추격 금지" in text_signals or "급등과열" in text_signals:
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
ml_hold_active = settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy
if not ml_hold_active:
should_buy = True
else:
if "급등과열" in text_signals or "이격도 과열" in text_signals or "매수금지" in text_signals:
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
if should_buy and not has_rule:
strong_buy_kw = [
"RSI 바닥 탈출 반등", "볼린저 하한선 터치",
"눌림목 진입 매수타점", "정배열 눌림목 매수",
]
has_strong_signal = any(k in text_signals_filtered for k in strong_buy_kw)
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 < 2):
should_buy = False
# ── 매매 실행 위임 ────────────────────────────────────────────────────
if should_buy:
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:
return await _execute_sell(
stock, db, holding, current_price, signals,
is_domestic, exchange_code, stock_code, stock_name,
)
return {"action": "HOLD", "stock": stock_name}
except Exception as e:
return {"action": "ERROR", "error": str(e)}