구조적인 부분 수정

This commit is contained in:
2026-02-24 11:38:35 +09:00
parent 6d9c9cb010
commit 883f87753c
15 changed files with 789 additions and 672 deletions

View File

@@ -7,6 +7,8 @@ import asyncio
import logging
import httpx
import pytz
import time
from collections import namedtuple
from app.services.korea_investment_api import korea_investment_api
from app.core.database import get_db, AsyncSessionLocal
@@ -27,6 +29,58 @@ from app.services.loss_tracker import loss_tracker
router = APIRouter()
logger = logging.getLogger(__name__)
# 매매 규칙 namedtuple (get_rule_based_recommendations_from_db/realtime 공용)
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
"""StockAnalysis ORM 행 → stock_dict 변환 헬퍼 (중복 제거).
rule=None 이면 '고점수 분석 종목' 폴백 값을 채운다.
"""
base = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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"),
}
if rule is not None:
_a = actions or {}
base.update({
"rule_id": rule.id,
"rule_name": rule.name,
"rule_description": rule.description,
"rule_priority": rule.priority,
"take_profit": _a.get("take_profit", 3.0),
"stop_loss": _a.get("stop_loss", -1.5),
"max_add_buys": _a.get("max_add_buys", 3),
"add_buy_cooldown_min": _a.get("add_buy_cooldown_min", 15),
})
else:
base.update({
"rule_id": None,
"rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99,
"take_profit": 3.0,
"stop_loss": -1.5,
"max_add_buys": 2,
"add_buy_cooldown_min": 30,
})
return base
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
@@ -170,7 +224,7 @@ def analyze_custom_signals(chart_data):
close = closes[-1]
# 🔥 이격도 계산 (추격 매수 방지) - 더 엄격하게
disparity = (close / ma20) * 100
disparity = (close / ma20) * 100 if ma20 else 100
# 🚫 급등 종목 필터: 최근 3일 상승률 체크
if len(closes) >= 4:
@@ -428,6 +482,11 @@ def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict:
return {"signal": None, "description": "변동성 돌파 미달"}
# check_market_regime() 인메모리 캐시 (KIS API 반복 호출 방지)
_market_regime_cache: dict = {"result": None, "ts": 0.0}
_MARKET_REGIME_CACHE_TTL = 120 # 2분
async def check_market_regime() -> dict:
"""시장 레짐 판별 (CLAUDE.md 섹션 7.1: 200일 MA + 기울기)
@@ -444,7 +503,14 @@ async def check_market_regime() -> dict:
if not is_market_open():
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
# 캐시 히트: TTL 내 이전 결과 반환 (KIS API 반복 호출 방지)
now_ts = time.monotonic()
if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL:
logger.debug("check_market_regime: cache hit")
return _market_regime_cache["result"]
# KODEX 200 (069500) = KOSPI 200 추종 ETF 사용 (지수코드 0001은 daily-price API 미지원)
logger.info("check_market_regime: KIS API 호출 (캐시 미스)")
kospi_chart = await korea_investment_api.get_daily_chart("069500", "D", limit=240)
if "output2" not in kospi_chart or not kospi_chart["output2"]:
@@ -480,9 +546,14 @@ async def check_market_regime() -> dict:
drop_from_ma20 = ((current_price - ma20) / ma20) * 100
def _cache_and_return(result: dict) -> dict:
_market_regime_cache["result"] = result
_market_regime_cache["ts"] = time.monotonic()
return result
# 강한 하락장: 코스피 < MA60 또는 5일 연속 하락
if current_price < ma60 or consecutive_down >= 5:
return {
return _cache_and_return({
"regime": "strong_bearish",
"regime_200d": "bear",
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
@@ -491,11 +562,11 @@ async def check_market_regime() -> dict:
"max_trades": 1,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
# 약세장: 코스피 < MA20
if current_price < ma20:
return {
return _cache_and_return({
"regime": "bearish",
"regime_200d": "bear",
"message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})",
@@ -504,10 +575,10 @@ async def check_market_regime() -> dict:
"max_trades": settings.BEAR_MARKET_MAX_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
# 상승장: 200일 MA 기반
return {
return _cache_and_return({
"regime": "bullish",
"regime_200d": "bull" if is_bull_200 else "bear",
"message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})",
@@ -516,7 +587,7 @@ async def check_market_regime() -> dict:
"max_trades": settings.MAX_DAILY_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
except Exception as e:
logger.error(f"시장 지수 확인 실패: {e}")
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
@@ -1136,6 +1207,65 @@ async def get_rule_based_recommendations(
return result
async def _load_active_rules_from_db(db: AsyncSession) -> list:
"""trading_rules 테이블에서 활성 규칙 로드.
DB가 비어있으면 JSON 파일 → 하드코딩 폴백 순으로 시도한다.
"""
from app.models.models import TradingRule as TradingRuleModel
try:
result = await db.execute(
select(TradingRuleModel)
.where(TradingRuleModel.is_active == True)
.order_by(TradingRuleModel.priority.desc())
)
db_rules = result.scalars().all()
if db_rules:
logger.info(f"trading_rules DB에서 {len(db_rules)}개 활성 규칙 로드")
return [
Rule(id=r.id, name=r.name, description=r.description or "",
rule_type=r.rule_type, priority=r.priority,
conditions=r.conditions, actions=r.actions, is_active=True)
for r in db_rules
]
except Exception as e:
logger.warning(f"trading_rules DB 조회 실패: {e}")
# 폴백 1: JSON 파일
try:
import json as _json
from pathlib import Path as _Path
config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = _json.load(f)
rules_data = [r for r in config.get("rules", []) if r.get("is_active")]
if rules_data:
logger.info(f"trading_rules DB 비어있음 — JSON 파일에서 {len(rules_data)}개 규칙 로드")
return [
Rule(id=i + 1, name=r["name"], description=r.get("description", ""),
rule_type=r["rule_type"].upper(), priority=r.get("priority", i + 1),
conditions=r.get("conditions", {}), actions=r.get("actions", {}),
is_active=True)
for i, r in enumerate(rules_data)
]
except Exception as e:
logger.warning(f"JSON 규칙 파일 로드 실패: {e}")
# 폴백 2: 하드코딩 기본 규칙
logger.warning("규칙 소스 없음 — 하드코딩 기본 규칙 사용")
return [
Rule(
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙 (CLAUDE.md 섹션 6)",
rule_type="MEAN_REVERSION", priority=1,
conditions={"rsi_bull_max": 25, "rsi_bear_max": 35,
"bb_lower_pct": 3.0, "bb_upper_pct": 1.5},
actions={"take_profit": 3.0, "stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_add_buys": 2, "add_buy_cooldown_min": 30},
is_active=True
)
]
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
"""RSI+볼린저 평균회귀 매수 조건 확인 (CLAUDE.md 섹션 7.2)
@@ -1181,65 +1311,17 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"""DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)"""
logger.info("Rule-based recommendations from DB")
# JSON 파일에서 규칙 로드
import json
from pathlib import Path
from collections import namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
try:
config_path = Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
rules_data = config.get("rules", [])
active_rules = []
for idx, rule_data in enumerate(rules_data, start=1):
if rule_data.get("is_active", False):
active_rules.append(Rule(
id=idx,
name=rule_data["name"],
description=rule_data["description"],
rule_type=rule_data["rule_type"].upper(),
priority=rule_data.get("priority", idx),
conditions=rule_data.get("conditions", {}),
actions=rule_data.get("actions", {}),
is_active=True
))
logger.info(f"Loaded {len(active_rules)} active trading rules from config file")
except Exception as e:
logger.error(f"Failed to load trading rules config: {e}")
# 폴백: 기본 규칙 사용
active_rules = [
Rule(
id=1,
name="RSI 과매도 반등",
description="RSI 30 이하 과매도 종목",
rule_type="BUY",
priority=1,
conditions={"rsi_threshold": 30},
actions={
"take_profit": 5.0,
"stop_loss": 3.0,
"position_sizing": {"target_ratio": 0.15}
},
is_active=True
)
]
logger.warning(f"Using fallback rule: {len(active_rules)} rule(s)")
active_rules = await _load_active_rules_from_db(db)
logger.info(f"Found {len(active_rules)} active trading rules")
# 오늘 날짜의 분석 데이터 조회 (중복 종목은 score 높은 것 우선)
today = date.today()
# 오늘·어제 분석 데이터 조회 (KST 날짜 기준, 배치 미실행 시 전날 데이터 사용)
today_kst = datetime.now(KST).date()
since_date = today_kst - timedelta(days=1) # 어제~오늘 포함
result = await db.execute(
select(StockAnalysis)
.where(StockAnalysis.analysis_date >= today)
.order_by(StockAnalysis.score.desc())
.where(StockAnalysis.analysis_date >= since_date)
.order_by(StockAnalysis.analysis_date.desc(), StockAnalysis.score.desc())
)
raw_stocks = result.scalars().all()
# 종목코드 기준 중복 제거 (score 내림차순 정렬 후 첫 번째만)
@@ -1264,7 +1346,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
}
logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}")
logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today_kst}")
# 규칙별로 매칭되는 종목 찾기
rule_matches = []
@@ -1313,38 +1395,13 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
# 여기까지 왔으면 rule_matched_stocks에 추가
rule_matched_stocks.append(stock)
# 점수순 정렬
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
# 점수순 정렬 (score=None 안전 처리)
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
# 이하 로직은 그대로 (Top N, rule_matches, stock_to_rules, 실시간 가격 업데이트 등)
rule_stocks = []
for stock in rule_matched_stocks[:10]:
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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": rule.id,
"rule_name": rule.name,
"rule_description": rule.description,
"rule_priority": rule.priority,
"take_profit": actions.get("take_profit", 3.0),
"stop_loss": actions.get("stop_loss", -1.5),
"max_add_buys": actions.get("max_add_buys", 3),
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15),
}
stock_dict = _stock_analysis_to_dict(stock, rule=rule, actions=actions)
rule_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -1381,32 +1438,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
if stock.stock_code in already_added:
continue
already_added.add(stock.stock_code)
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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": None,
"rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99,
"take_profit": 3.0,
"stop_loss": -1.5,
"max_add_buys": 2,
"add_buy_cooldown_min": 30,
}
stock_dict = _stock_analysis_to_dict(stock)
fallback_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -1442,82 +1474,23 @@ 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()
# DB에 저장된 실제 배치 분석 날짜 (TIMESTAMP/DATE 모두 YYYY-MM-DD 반환)
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=date.today())
).isoformat(),
}
async def get_rule_based_recommendations_realtime():
"""
실시간 KIS API 분석 (느림 - 30초)
기존 방식 유지
실시간 KIS API 분석 (느림 - 배치 주기에 호출됨)
규칙은 trading_rules DB → JSON 파일 → 하드코딩 순으로 로드한다.
"""
# TODO: 데이터베이스 연결 후 실제 규칙 조회
# 현재는 샘플 규칙으로 동작
logger.info("Rule-based recommendations requested (realtime mode)")
# 샘플 규칙 생성 (실제로는 DB에서 조회)
from collections import namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
active_rules = [
Rule(
id=1,
name="RSI 과매도 반등",
description="RSI 40 이하 과매도 종목",
rule_type="BUY",
priority=1,
conditions={"rsi_threshold": 40},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
Rule(
id=2,
name="골든크로스 추세",
description="5일선이 20일선 돌파",
rule_type="BUY",
priority=2,
conditions={"sma_short": 5, "sma_long": 20},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
Rule(
id=3,
name="볼린저밴드 하단",
description="볼린저밴드 하단 근처 반등",
rule_type="BUY",
priority=3,
conditions={"bb_threshold": 0.2},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
]
async with AsyncSessionLocal() as _db:
active_rules = await _load_active_rules_from_db(_db)
logger.info(f"Found {len(active_rules)} active trading rules (realtime)")
# active_rules가 비어있으면 기본 지표로 분석
if not active_rules:
@@ -1597,9 +1570,9 @@ 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개 종목으로 제한
stocks_to_analyze = all_stocks[:settings.ANALYSIS_STOCK_COUNT]
# 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석)
try:
@@ -1653,54 +1626,44 @@ async def get_rule_based_recommendations_realtime():
logger.info(f"Successfully analyzed {len(valid_analyzed_stocks)} stocks")
# 시장 레짐 판별 (DB 모드와 동일한 로직)
market_regime_info = await check_market_regime()
current_market_regime_str = market_regime_info.get("regime_200d", "bear")
# 규칙별로 매칭되는 종목 찾기 (이미 분석된 데이터 재사용)
rule_matches = []
recommendations_by_rule = []
for rule in active_rules:
logger.info(f"Applying rule: {rule.name}")
# 규칙 조건 파싱
conditions = rule.conditions
actions = rule.actions
# 이미 분석된 종목들 중에서 규칙에 맞는 종목 필터링
rule_matched_stocks = []
for stock in valid_analyzed_stocks:
# ETF/레버리지/인버스/KODEX/선물 종목 제외
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
if any(k in stock.stock_name for k in etf_keywords):
continue
# 규칙 조건 체크
matches = True
# RSI 조건 체크
if "rsi_threshold" in conditions:
rsi_threshold = conditions["rsi_threshold"]
if not any(f"RSI" in signal for signal in stock.signals):
matches = False
# Trend filter: only allow long entries in uptrend
if TREND_FILTER_ENABLED:
try:
t_ok = False
if stock.ma20 is not None and stock.ma60 is not None:
t_ok = (stock.ma20 > stock.ma60) and (stock.current_price > stock.ma20)
else:
# Fallback compute from price history is skipped here (already done in analyze_stock)
t_ok = False
if not t_ok:
matches = False
except Exception:
matches = False
# === 통합 규칙 조건 매칭 (DB 모드와 동일한 로직) ===
if rule.rule_type == "MEAN_REVERSION":
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime_str)
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
matches = False # 미구현 (DB 모드와 동일)
else:
# BUY 타입: RSI 임계값 체크
rsi_threshold = conditions.get("rsi_threshold", 40)
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
if matches:
rule_matched_stocks.append(stock)
# 점수순 정렬
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
# 점수순 정렬 (score=None 안전 처리)
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
# 규칙별로 종목 저장
rule_stocks = []
for rec in rule_matched_stocks[:10]: # 규칙당 상위 10개
@@ -1764,65 +1727,15 @@ async def get_rule_based_recommendations_realtime():
stock["change_rate"] = updated["change_rate"]
# 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장)
from app.models.models import StockAnalysis
# N+1 SELECT 루프 → 단일 bulk UPSERT로 대체 (성능 개선)
from app.services.analysis_db_service import bulk_upsert_analysis as _bulk_upsert
from datetime import date as _date
_today = _date.today()
_now = datetime.now()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
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,
))
saved = await _bulk_upsert(_db, _items, _today)
await _db.commit()
logger.info(f"✅ [Batch] DB upsert 완료: {len(valid_analyzed_stocks)}종목 저장")
logger.info(f"✅ [Batch] DB upsert 완료: {saved}종목 저장")
return {
"total": len(rule_matches),
"active_rules": len(active_rules),
@@ -2614,7 +2527,7 @@ async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
if total_processed >= max_trades:
break
for stock in rule_group["stocks"][:2]: # 규칙당 상위 2개만
for stock in rule_group["stocks"][:settings.AUTO_TRADE_TOP_N_PER_RULE]:
if total_processed >= max_trades:
break