DB오류 처리중

This commit is contained in:
2026-02-23 16:01:17 +09:00
parent 138bf494db
commit 6d9c9cb010
12 changed files with 368 additions and 109 deletions

View File

@@ -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% 손절(권장)