DB오류 처리중
This commit is contained in:
@@ -1398,7 +1398,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||||
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
|
||||
"ml_signal": stock.ml_signal,
|
||||
"price_date": stock.analysis_date.strftime("%Y%m%d"),
|
||||
"rule_id": 0,
|
||||
"rule_id": None,
|
||||
"rule_name": "고점수 분석 종목",
|
||||
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
|
||||
"rule_priority": 99,
|
||||
@@ -1412,7 +1412,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||||
|
||||
if fallback_stocks:
|
||||
recommendations_by_rule.append({
|
||||
"rule_id": 0,
|
||||
"rule_id": None,
|
||||
"rule_name": "고점수 분석 종목",
|
||||
"rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
|
||||
"rule_type": "ANALYSIS",
|
||||
@@ -1442,6 +1442,11 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||||
"recommendations": rule_matches,
|
||||
"recommendations_by_rule": recommendations_by_rule,
|
||||
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
|
||||
# DB에 저장된 실제 배치 분석 시각 (API 요청 시각이 아님)
|
||||
"batch_time": max(
|
||||
(s.analysis_date for s in all_analyzed_stocks),
|
||||
default=datetime.now()
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1562,7 +1567,7 @@ async def get_rule_based_recommendations_realtime():
|
||||
"signals": rec.signals,
|
||||
"score": rec.score,
|
||||
"price_date": rec.price_date,
|
||||
"rule_id": 0,
|
||||
"rule_id": None,
|
||||
"rule_name": "기본 추천",
|
||||
"rule_description": "RSI + 이동평균 기반 추천",
|
||||
"rule_priority": 1,
|
||||
@@ -1578,7 +1583,7 @@ async def get_rule_based_recommendations_realtime():
|
||||
"analyzed_stocks": len(stocks_to_analyze),
|
||||
"recommendations": default_stocks,
|
||||
"recommendations_by_rule": [{
|
||||
"rule_id": 0,
|
||||
"rule_id": None,
|
||||
"rule_name": "기본 추천 (RSI + 이동평균)",
|
||||
"rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목",
|
||||
"rule_type": "BUY",
|
||||
@@ -1591,11 +1596,29 @@ async def get_rule_based_recommendations_realtime():
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(active_rules)} active trading rules")
|
||||
|
||||
|
||||
# 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한)
|
||||
all_stocks = await get_all_tradable_stocks()
|
||||
stocks_to_analyze = all_stocks[:10] # 상위 10개 종목으로 제한
|
||||
|
||||
|
||||
# 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석)
|
||||
try:
|
||||
from app.models.models import Portfolio as _PortfolioModel
|
||||
async with AsyncSessionLocal() as _pf_db:
|
||||
_pf_result = await _pf_db.execute(
|
||||
select(_PortfolioModel).where(_PortfolioModel.quantity > 0)
|
||||
)
|
||||
_pf_stocks = _pf_result.scalars().all()
|
||||
existing_codes = {code for code, _ in stocks_to_analyze}
|
||||
for _pf in _pf_stocks:
|
||||
if _pf.stock_code not in existing_codes:
|
||||
stocks_to_analyze = list(stocks_to_analyze)
|
||||
stocks_to_analyze.append((_pf.stock_code, _pf.stock_name or _pf.stock_code))
|
||||
existing_codes.add(_pf.stock_code)
|
||||
logger.info(f"Added portfolio stocks → total {len(stocks_to_analyze)} stocks to analyze")
|
||||
except Exception as _e:
|
||||
logger.warning(f"포트폴리오 종목 추가 실패 (non-critical): {_e}")
|
||||
|
||||
# ✅ 최적화: 모든 종목을 한 번만 분석 (모든 지표 포함)
|
||||
logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...")
|
||||
criteria = RecommendationCriteria(
|
||||
@@ -1603,7 +1626,8 @@ async def get_rule_based_recommendations_realtime():
|
||||
use_moving_average=True,
|
||||
use_macd=True,
|
||||
use_bollinger=True,
|
||||
use_stochastic=True
|
||||
use_stochastic=True,
|
||||
for_batch=True, # score=0 종목도 반환하여 모든 지표 DB 저장
|
||||
)
|
||||
|
||||
# 배치 처리로 종목 분석 (Rate Limiting)
|
||||
@@ -1739,50 +1763,66 @@ async def get_rule_based_recommendations_realtime():
|
||||
stock["current_price"] = updated["current_price"]
|
||||
stock["change_rate"] = updated["change_rate"]
|
||||
|
||||
# 실시간 분석 결과를 DB에 저장
|
||||
# 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장)
|
||||
from app.models.models import StockAnalysis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import AsyncSessionLocal
|
||||
import datetime
|
||||
|
||||
db = AsyncSessionLocal()
|
||||
today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for rec in top_recommendations:
|
||||
# 중복 저장 방지: 오늘자+종목코드 기준
|
||||
exists = await db.execute(
|
||||
StockAnalysis.__table__.select().where(
|
||||
StockAnalysis.stock_code == rec["stock_code"],
|
||||
StockAnalysis.analysis_date == today
|
||||
)
|
||||
)
|
||||
if not exists.first():
|
||||
stock_obj = StockAnalysis(
|
||||
stock_code=rec["stock_code"],
|
||||
stock_name=rec["stock_name"],
|
||||
analysis_date=today,
|
||||
current_price=rec["current_price"],
|
||||
change_rate=rec.get("change_rate", 0),
|
||||
volume=rec.get("volume", 0),
|
||||
rsi=rec.get("rsi", None),
|
||||
ma5=rec.get("ma5", None),
|
||||
ma20=rec.get("ma20", None),
|
||||
ma60=rec.get("ma60", None),
|
||||
stochastic_k=rec.get("stochastic_k", None),
|
||||
stochastic_d=rec.get("stochastic_d", None),
|
||||
bollinger_upper=rec.get("bollinger_upper", None),
|
||||
bollinger_lower=rec.get("bollinger_lower", None),
|
||||
bollinger_middle=rec.get("bollinger_middle", None),
|
||||
signals=rec.get("signals", []),
|
||||
score=rec.get("score", 0),
|
||||
ml_score=rec.get("ml_score"),
|
||||
ml_confidence=rec.get("ml_confidence"),
|
||||
ml_signal=rec.get("ml_signal"),
|
||||
)
|
||||
db.add(stock_obj)
|
||||
try:
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
from datetime import date as _date
|
||||
_today = _date.today()
|
||||
_now = datetime.now()
|
||||
async with AsyncSessionLocal() as _db:
|
||||
# valid_analyzed_stocks = StockRecommendation Pydantic 모델 리스트
|
||||
# top_recommendations에만 저장하면 급등과열/필터로 모두 차단될 때 아무것도 저장 안 됨
|
||||
for rec in valid_analyzed_stocks:
|
||||
_existing = (await _db.execute(
|
||||
select(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == rec.stock_code,
|
||||
StockAnalysis.analysis_date >= _today,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if _existing:
|
||||
# 기존 레코드 업데이트 (지표 갱신)
|
||||
_existing.analysis_date = _now # 배치 실행 시각으로 갱신
|
||||
_existing.current_price = rec.current_price
|
||||
_existing.change_rate = rec.change_rate or 0
|
||||
_existing.volume = rec.volume or 0
|
||||
_existing.rsi = rec.rsi
|
||||
_existing.ma5 = rec.ma5
|
||||
_existing.ma20 = rec.ma20
|
||||
_existing.ma60 = rec.ma60
|
||||
_existing.stochastic_k = rec.stochastic_k
|
||||
_existing.stochastic_d = rec.stochastic_d
|
||||
_existing.bollinger_upper = rec.bollinger_upper
|
||||
_existing.bollinger_lower = rec.bollinger_lower
|
||||
_existing.bollinger_middle = rec.bollinger_middle
|
||||
_existing.signals = rec.signals or []
|
||||
_existing.score = rec.score
|
||||
_existing.ml_score = rec.ml_score
|
||||
_existing.ml_confidence = rec.ml_confidence
|
||||
_existing.ml_signal = rec.ml_signal
|
||||
else:
|
||||
_db.add(StockAnalysis(
|
||||
stock_code=rec.stock_code,
|
||||
stock_name=rec.stock_name,
|
||||
analysis_date=_now,
|
||||
current_price=rec.current_price,
|
||||
change_rate=rec.change_rate or 0,
|
||||
volume=rec.volume or 0,
|
||||
rsi=rec.rsi,
|
||||
ma5=rec.ma5,
|
||||
ma20=rec.ma20,
|
||||
ma60=rec.ma60,
|
||||
stochastic_k=rec.stochastic_k,
|
||||
stochastic_d=rec.stochastic_d,
|
||||
bollinger_upper=rec.bollinger_upper,
|
||||
bollinger_lower=rec.bollinger_lower,
|
||||
bollinger_middle=rec.bollinger_middle,
|
||||
signals=rec.signals or [],
|
||||
score=rec.score,
|
||||
ml_score=rec.ml_score,
|
||||
ml_confidence=rec.ml_confidence,
|
||||
ml_signal=rec.ml_signal,
|
||||
))
|
||||
await _db.commit()
|
||||
logger.info(f"✅ [Batch] DB upsert 완료: {len(valid_analyzed_stocks)}종목 저장")
|
||||
return {
|
||||
"total": len(rule_matches),
|
||||
"active_rules": len(active_rules),
|
||||
@@ -1848,8 +1888,8 @@ async def _mcp_validate_buy(stock: dict) -> tuple:
|
||||
return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MCP 검증 실패 (스킵하고 계속): {e}")
|
||||
return True, "mcp_unavailable" # 장애 시 매매 차단 안 함
|
||||
logger.warning(f"⚠️ MCP 검증 실패 — 매매 차단: {e}")
|
||||
return False, "mcp_unavailable" # 장애 시 매매 차단 (fail-closed)
|
||||
|
||||
|
||||
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
|
||||
@@ -2055,10 +2095,11 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
|
||||
sell_keywords = [
|
||||
"역배열 하락 매도타점",
|
||||
"RSI 과매수",
|
||||
"데드크로스",
|
||||
"RSI 과매수", # RSI > 70 (rsi_signal 함수 출력과 일치)
|
||||
"스토캐스틱 과매수", # stochastic K > 70 (stochastic_signal 함수 출력과 일치)
|
||||
"데드크로스", # "MACD 데드크로스" 부분 매칭
|
||||
"이동평균 역배열",
|
||||
"볼린저 상한선 돌파",
|
||||
"볼린저 상한선 돌파", # price > upper_band (bollinger_signal 함수 출력과 일치)
|
||||
"손절라인 이탈",
|
||||
]
|
||||
should_sell = any(k in text_signals for k in sell_keywords)
|
||||
@@ -2085,7 +2126,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
logger.info(f"🤖 ML 관망 신호로 매수 억제: {stock_name}")
|
||||
|
||||
# ✅ 규칙 기반 추천 종목도 안전장치 적용 (이격도 과열, 급등 체크)
|
||||
has_rule = stock.get("rule_id") is not None or stock.get("rule_name") is not None
|
||||
# rule_id가 있을 때만 실제 규칙 매칭으로 간주 (폴백 "고점수 분석 종목"은 제외)
|
||||
has_rule = stock.get("rule_id") is not None
|
||||
if has_rule:
|
||||
# 이격도 과열 시 매수 금지
|
||||
if "이격도 과열" in text_signals or "매수금지" in text_signals:
|
||||
@@ -2097,12 +2139,40 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
logger.info(f"🚫 규칙 추천이지만 급등/과열 금지: {stock_name}")
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
|
||||
|
||||
should_buy = True
|
||||
logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}")
|
||||
# ML이 명시적으로 HOLD를 내렸으면 규칙 기반 강제매수 금지
|
||||
# (ML 활성화 AND ML=HOLD AND 이전 단계에서 should_buy가 False로 설정됨)
|
||||
ml_hold_active = (
|
||||
settings.ML_ENSEMBLE_ENABLED
|
||||
and ml_signal_trade == "HOLD"
|
||||
and not should_buy
|
||||
)
|
||||
if ml_hold_active:
|
||||
logger.info(f"🤖 ML 관망으로 규칙 기반 강제매수 차단: {stock_name}")
|
||||
else:
|
||||
should_buy = True
|
||||
logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}")
|
||||
|
||||
# 매수 로직 (신호 또는 점수 기반)
|
||||
# 추가매수 허용: 보유 중이어도 비중 한도 내에서 추매 가능
|
||||
if (should_buy or (score_val >= 1)):
|
||||
# 폴백 종목(규칙 미매칭): 강한 신호 1개 이상 OR 약한 신호 2개 이상 + score ≥ 2 필요
|
||||
# 규칙 매칭 종목(has_rule=True)은 is_mean_reversion_candidate()를 통과했으므로 그대로 진행
|
||||
if should_buy and not has_rule:
|
||||
strong_buy_kw = [
|
||||
"RSI 바닥 탈출 반등",
|
||||
"볼린저 하한선 터치",
|
||||
"눌림목 진입 매수타점",
|
||||
"정배열 눌림목 매수",
|
||||
]
|
||||
has_strong_signal = any(k in text_signals for k in strong_buy_kw)
|
||||
matched_count = sum(1 for k in buy_keywords if k in text_signals)
|
||||
if not has_strong_signal and (matched_count < 2 or score_val < 2):
|
||||
should_buy = False
|
||||
logger.info(
|
||||
f"📉 복합 신호 부족으로 매수 억제: {stock_name}"
|
||||
f" (신호={matched_count}개, score={score_val:.1f})"
|
||||
)
|
||||
|
||||
# 매수 로직 (명시적 매수 신호 필요 — score 단독으로는 진입 불가)
|
||||
# 프론트 "관망/0%" 표시와 실행 로직 일치: should_buy=True여야만 진입
|
||||
if should_buy:
|
||||
# 🚫 추격매수 방지: 급등 종목 필터
|
||||
try:
|
||||
if is_domestic:
|
||||
@@ -2347,18 +2417,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
)
|
||||
db.add(new_holding)
|
||||
await db.commit()
|
||||
# 포트폴리오 캐시 즉시 업데이트
|
||||
# 포트폴리오 캐시 즉시 현금 차감 (미수 방지)
|
||||
# 캐시 완전 초기화 대신 available_cash만 즉시 차감 → 동일 사이클 내 연속 매수 차단
|
||||
try:
|
||||
from app.api.endpoints.portfolio import portfolio_cache
|
||||
# 캐시 완전 초기화 - 다음 조회시 강제로 KIS API 호출
|
||||
portfolio_cache.holdings = []
|
||||
portfolio_cache.summary = None
|
||||
portfolio_cache.last_synced = None
|
||||
portfolio_cache.last_trade_id = None
|
||||
order_cost = quantity * current_price
|
||||
if portfolio_cache.summary:
|
||||
old_cash = float(portfolio_cache.summary.get("available_cash", 0))
|
||||
portfolio_cache.summary["available_cash"] = max(0.0, old_cash - order_cost)
|
||||
logger.info(
|
||||
f"💸 예수금 즉시 차감: {stock_name}({stock_code}) "
|
||||
f"{order_cost:,.0f}원 차감 → 잔여 {portfolio_cache.summary['available_cash']:,.0f}원"
|
||||
)
|
||||
portfolio_cache.change_flag = True
|
||||
logger.info(f"🔄 Portfolio cache CLEARED after BUY {stock_name}({stock_code})")
|
||||
portfolio_cache.last_trade_id = None # 다음 sync 강제
|
||||
except Exception as e:
|
||||
logger.error(f"Portfolio cache clear failed after BUY: {e}")
|
||||
logger.error(f"Portfolio cache cash deduction failed after BUY: {e}")
|
||||
return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success}
|
||||
|
||||
except Exception as e:
|
||||
@@ -2371,8 +2445,13 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
# 매도 로직
|
||||
elif should_sell and holding:
|
||||
quantity = holding.quantity
|
||||
profit_loss_rate = float(holding.profit_loss_rate or 0)
|
||||
|
||||
# DB 저장값은 동기화 지연으로 stale할 수 있으므로 실시간 가격으로 재계산
|
||||
_avg = float(holding.average_price or 0)
|
||||
if _avg > 0 and current_price > 0:
|
||||
profit_loss_rate = ((current_price - _avg) / _avg) * 100
|
||||
else:
|
||||
profit_loss_rate = float(holding.profit_loss_rate or 0)
|
||||
|
||||
# 익절/손절 조건 확인
|
||||
take_profit = stock.get("take_profit", 3.0) # 3% 익절(권장)
|
||||
stop_loss = stock.get("stop_loss", -1.5) # -1.5% 손절(권장)
|
||||
|
||||
Reference in New Issue
Block a user