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

542 lines
26 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__)
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:
# 500 에러 → MCP 서버 내부 오류 → 룰 기반으로 우회
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_auto_trade_background(recommendations_response: dict, db: AsyncSession):
# recommendations_response should be a dict containing 'recommendations_by_rule'.
if not isinstance(recommendations_response, dict):
logger.error("recommendations_response is not a dict: %r", recommendations_response)
return
try:
trade_results = []
total_processed = 0
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()}
# normalize recommendations_by_rule early so we iterate over a list of groups
rec_groups = recommendations_response.get("recommendations_by_rule", [])
if isinstance(rec_groups, dict):
# older format: {rule_name: [stocks]}
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 = {}
for rule_group in rec_groups:
# make sure we have a dict with expected keys
if not isinstance(rule_group, dict):
logger.warning("Invalid rule_group in recommendations_response: %r", rule_group)
continue
stocks_list = rule_group.get("stocks")
if not isinstance(stocks_list, list):
logger.warning("Invalid stocks list in rule_group: %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]:
# ensure stock is a dict before proceeding
if not isinstance(stock, dict):
logger.warning("Skipping invalid stock entry in sorted_stocks: %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:
# stock is dict so we can safely access get
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} - 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):
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}
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))
held_count = len(held_result.scalars().all())
if held_count >= 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
elif not has_rule:
# rule 없는 경우 급등과열 신호도 차단
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:
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:
if is_domestic:
closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
else:
closes = [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:
pass
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 as e:
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
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)
# 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,
)
)
pending_orders = pending_check.scalars().all()
if pending_orders:
return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"}
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,
)
)
daily_buys = daily_buy_check.scalars().all()
if len(daily_buys) >= 2:
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
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"}
mcp_ok, mcp_reason = await _mcp_validate_buy(stock)
if not mcp_ok:
return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason}
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:
try:
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:
if holding:
new_total_qty = holding.quantity + quantity
prev_cost = float(holding.average_price or 0) * holding.quantity
holding.quantity = new_total_qty
holding.average_price = Decimal(str((prev_cost + current_price * quantity) / new_total_qty))
holding.current_price = Decimal(str(current_price))
else:
new_holding = Portfolio(
stock_code=stock_code, stock_name=stock_name, quantity=quantity,
average_price=Decimal(str(current_price)), current_price=Decimal(str(current_price)),
profit_loss=Decimal("0"), profit_loss_rate=Decimal("0")
)
db.add(new_holding)
await db.commit()
try:
from app.api.endpoints.portfolio import portfolio_cache
portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price)
except Exception:
pass
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)}
else:
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
elif should_sell and holding:
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)
should_execute_sell = profit_loss_rate >= take_profit or profit_loss_rate <= stop_loss or should_sell
if should_execute_sell:
try:
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)
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}
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:
await db.delete(holding)
await db.commit()
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
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)}
return {"action": "HOLD", "stock": stock_name}
except Exception as e:
return {"action": "ERROR", "error": str(e)}