- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1081 lines
47 KiB
Python
1081 lines
47 KiB
Python
"""
|
||
추천 종목 및 자동매매 API 엔드포인트
|
||
|
||
- GET /rule-based: 룰 기반 추천 종목 조회 (DB)
|
||
- GET /rule-based/realtime: 실시간 분석 후 DB upsert + 결과 반환
|
||
- POST /execute-trade: 추천 기반 자동매매 즉시 실행 (수동 트리거)
|
||
- 내부 유틸: execute_auto_trade_background() — auto_trade_scheduler 에서 호출
|
||
"""
|
||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||
from typing import List, Optional
|
||
from datetime import datetime, date, timedelta
|
||
import asyncio
|
||
import re
|
||
import logging
|
||
from collections import namedtuple
|
||
|
||
from app.core.database import get_db, AsyncSessionLocal
|
||
from app.models.models import StockAnalysis
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
|
||
from app.core.config import settings
|
||
from app.services.market_analyzer import (
|
||
check_market_regime, get_all_tradable_stocks, analyze_stock,
|
||
analyze_stock_for_batch,
|
||
RecommendationCriteria, StockRecommendation, KST
|
||
)
|
||
from app.services.trade_executor import execute_auto_trade_background
|
||
from app.services.mean_reversion_strategy import MarketRegime
|
||
import pytz
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_KST = pytz.timezone("Asia/Seoul")
|
||
|
||
|
||
def _today_kst() -> date:
|
||
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
|
||
return datetime.now(_KST).date()
|
||
|
||
|
||
def _row_price_date_v2(stock) -> tuple:
|
||
"""DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다.
|
||
|
||
장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다.
|
||
갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True.
|
||
"""
|
||
ad = stock.analysis_date
|
||
if isinstance(ad, datetime):
|
||
ad = ad.date()
|
||
ts = getattr(stock, "updated_at", None)
|
||
if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0):
|
||
return "", True
|
||
return ad.strftime("%Y%m%d"), False
|
||
|
||
|
||
def _persistable_items_v2(items, today: date) -> list:
|
||
"""분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다.
|
||
|
||
price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처
|
||
(analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지.
|
||
"""
|
||
ts = today.strftime("%Y%m%d")
|
||
out = []
|
||
for it in items:
|
||
pd_ = str(getattr(it[2], "price_date", "") or "")
|
||
if pd_ and pd_ != ts:
|
||
continue
|
||
out.append(it)
|
||
return out
|
||
|
||
|
||
# ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ────────────────────────────────
|
||
# 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼
|
||
# 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다.
|
||
_BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열")
|
||
|
||
|
||
def _block_reasons_v2(signals) -> list:
|
||
out = []
|
||
for sig in (signals or []):
|
||
text = str(sig)
|
||
if any(m in text for m in _BLOCK_MARKERS):
|
||
out.append(text)
|
||
return out
|
||
|
||
|
||
_VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)")
|
||
|
||
|
||
def _stock_volume_ratio_v2(stock):
|
||
"""종목의 거래량 배수(20일 평균 대비). 없으면 None.
|
||
|
||
StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유),
|
||
1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열
|
||
'거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로
|
||
복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라
|
||
보수적 방향으로만 틀린다.
|
||
"""
|
||
ratio = getattr(stock, "volume_ratio", None)
|
||
if ratio is not None:
|
||
return ratio
|
||
for sig in (getattr(stock, "signals", None) or []):
|
||
m = _VOL_RATIO_SIGNAL_RE.search(str(sig))
|
||
if m:
|
||
return float(m.group(1))
|
||
return None
|
||
|
||
|
||
def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool:
|
||
"""realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정.
|
||
|
||
[하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가
|
||
되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다.
|
||
- SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외
|
||
- 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외
|
||
RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요).
|
||
"""
|
||
if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"):
|
||
return False
|
||
if _block_reasons_v2(getattr(stock, "signals", None)):
|
||
return False
|
||
conditions = conditions or {}
|
||
has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions)
|
||
has_vol_key = "volume_above_average" in conditions
|
||
if conditions and not (has_rsi_key or has_vol_key):
|
||
# 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed
|
||
return False
|
||
if stock.rsi is None:
|
||
return False
|
||
# rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽.
|
||
if has_rsi_key:
|
||
try:
|
||
if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])):
|
||
return False
|
||
if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])):
|
||
return False
|
||
except (TypeError, ValueError):
|
||
return False
|
||
elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지
|
||
return False
|
||
if has_vol_key:
|
||
# 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed).
|
||
ratio = _stock_volume_ratio_v2(stock)
|
||
try:
|
||
if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])):
|
||
return False
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return True
|
||
|
||
|
||
# trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화).
|
||
_ML_HOLD_BLOCK_CONF_V2 = 0.6
|
||
|
||
|
||
def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list:
|
||
"""trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유.
|
||
|
||
- ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)')
|
||
- ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가
|
||
매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출).
|
||
"""
|
||
sig = str(ml_signal or "").upper()
|
||
reasons = []
|
||
try:
|
||
conf = float(ml_confidence or 0)
|
||
except (TypeError, ValueError):
|
||
conf = 0.0
|
||
if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2:
|
||
reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})")
|
||
if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED:
|
||
reasons.append("ML 신호 SELL")
|
||
return reasons
|
||
|
||
|
||
def _scanner_flags_v2(stock, rule=None) -> dict:
|
||
"""스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다.
|
||
|
||
buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의).
|
||
(MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정)
|
||
"""
|
||
reasons = _block_reasons_v2(getattr(stock, "signals", None))
|
||
ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None))
|
||
reasons = reasons + ml_reasons
|
||
matched = rule is not None
|
||
return {
|
||
"match_source": "rule" if matched else "fallback_high_score",
|
||
"match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)",
|
||
"block_reasons": reasons,
|
||
"buy_allowed": not reasons,
|
||
"ml_blocks_buy": bool(ml_reasons),
|
||
}
|
||
|
||
|
||
def _batch_time_iso_v2(stocks) -> str:
|
||
"""배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00).
|
||
|
||
DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST.
|
||
(이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시)
|
||
"""
|
||
latest = None
|
||
for st in stocks:
|
||
ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None)
|
||
if isinstance(ts, datetime) and (latest is None or ts > latest):
|
||
latest = ts
|
||
if latest is None:
|
||
d = max((st.analysis_date for st in stocks), default=_today_kst())
|
||
if isinstance(d, datetime):
|
||
d = d.date()
|
||
latest = datetime(d.year, d.month, d.day)
|
||
if latest.tzinfo is None:
|
||
latest = _KST.localize(latest)
|
||
else:
|
||
latest = latest.astimezone(_KST)
|
||
return latest.isoformat()
|
||
|
||
|
||
async def _overlay_realtime_v2(result: dict) -> dict:
|
||
"""Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다.
|
||
|
||
배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다.
|
||
원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다.
|
||
틱이 없는 종목은 배치 값 유지 + price_source="batch".
|
||
"""
|
||
from app.core.redis import get_json
|
||
|
||
lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []]
|
||
for g in result.get("recommendations_by_rule") or []:
|
||
lists.append(g.get("stocks") or [])
|
||
codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400]
|
||
if not codes:
|
||
return result
|
||
ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True)
|
||
tick_by = {
|
||
c: t for c, t in zip(codes, ticks)
|
||
if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None
|
||
}
|
||
|
||
def _apply(item: dict) -> dict:
|
||
new = dict(item)
|
||
t = tick_by.get(item.get("stock_code"))
|
||
if t:
|
||
new["batch_current_price"] = item.get("current_price")
|
||
new["batch_change_rate"] = item.get("change_rate")
|
||
new["current_price"] = int(t["price"])
|
||
new["change_rate"] = round(float(t["change_rate"]), 2)
|
||
new["price_source"] = "realtime"
|
||
else:
|
||
new["price_source"] = "batch"
|
||
return new
|
||
|
||
out = dict(result)
|
||
out["recommendations"] = [_apply(i) for i in lists[0]]
|
||
out["analyzed_stock_list"] = [_apply(i) for i in lists[1]]
|
||
out["recommendations_by_rule"] = [
|
||
{**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]}
|
||
for g in (result.get("recommendations_by_rule") or [])
|
||
]
|
||
out["realtime_overlay_count"] = len(tick_by)
|
||
return out
|
||
|
||
|
||
# 매매 규칙 namedtuple
|
||
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 변환 헬퍼 (중복 제거)."""
|
||
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": _row_price_date_v2(stock)[0],
|
||
"price_stale": _row_price_date_v2(stock)[1],
|
||
"price_source": "batch",
|
||
**_scanner_flags_v2(stock, rule),
|
||
}
|
||
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
|
||
|
||
@router.get("/recommendations")
|
||
async def get_recommendations(
|
||
use_macd: bool = Query(False, description="MACD 지표 사용"),
|
||
use_rsi: bool = Query(False, description="RSI 지표 사용"),
|
||
use_bollinger: bool = Query(False, description="볼린저밴드 사용"),
|
||
use_moving_average: bool = Query(False, description="이동평균선 사용"),
|
||
use_stochastic: bool = Query(False, description="스토케스틱 사용"),
|
||
use_adx: bool = Query(False, description="ADX 사용"),
|
||
use_obv: bool = Query(False, description="거래량(OBV) 사용"),
|
||
use_volatility_breakout: bool = Query(False, description="변동성 돌파 전략 사용"),
|
||
min_volume_ratio: float = Query(1.0, description="최소 거래량 비율 (평균 대비)"),
|
||
max_disparity: float = Query(200.0, description="최대 이격도 (20일선 대비 %)"),
|
||
max_stocks: int = Query(100, ge=1, le=200, description="분석할 최대 종목 수 (기본 100개, 최대 200개)"),
|
||
):
|
||
market_regime = await check_market_regime()
|
||
logger.info(f"📊 시장 상태: {market_regime['message']}")
|
||
|
||
criteria = RecommendationCriteria(
|
||
use_macd=use_macd,
|
||
use_rsi=use_rsi,
|
||
use_bollinger=use_bollinger,
|
||
use_moving_average=use_moving_average,
|
||
use_stochastic=use_stochastic,
|
||
use_adx=use_adx,
|
||
use_obv=use_obv,
|
||
use_volatility_breakout=use_volatility_breakout,
|
||
min_volume_ratio=min_volume_ratio,
|
||
max_disparity=max_disparity
|
||
)
|
||
|
||
if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv, use_volatility_breakout]):
|
||
criteria.use_rsi = True
|
||
criteria.use_moving_average = True
|
||
criteria.use_volatility_breakout = True
|
||
|
||
all_stocks = await get_all_tradable_stocks()
|
||
|
||
if not all_stocks:
|
||
return {
|
||
"total": 0,
|
||
"analyzed_stocks": 0,
|
||
"criteria": criteria.model_dump(),
|
||
"recommendations": [],
|
||
"message": "거래 가능한 종목을 조회할 수 없습니다"
|
||
}
|
||
|
||
stocks_to_analyze = all_stocks[:max_stocks]
|
||
logger.info(f"Analyzing {len(stocks_to_analyze)} stocks with criteria: {criteria.model_dump()}")
|
||
|
||
batch_size = 5
|
||
delay_between_batches = 1.2
|
||
all_results = []
|
||
|
||
for i in range(0, len(stocks_to_analyze), batch_size):
|
||
batch = stocks_to_analyze[i:i + batch_size]
|
||
tasks = [analyze_stock(code, name, criteria) for code, name in batch]
|
||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
all_results.extend(batch_results)
|
||
|
||
if i + batch_size < len(stocks_to_analyze):
|
||
await asyncio.sleep(delay_between_batches)
|
||
|
||
regime_min_score = market_regime.get("min_score", 1)
|
||
recommendations = [
|
||
r for r in all_results
|
||
if r is not None
|
||
and not isinstance(r, Exception)
|
||
and r.current_price > 5000
|
||
and r.score >= regime_min_score
|
||
]
|
||
recommendations.sort(key=lambda x: x.score, reverse=True)
|
||
|
||
return {
|
||
"total": len(recommendations),
|
||
"analyzed_stocks": len(stocks_to_analyze),
|
||
"criteria": criteria.model_dump(),
|
||
"market_regime": market_regime,
|
||
"recommendations": recommendations[:20]
|
||
}
|
||
|
||
# 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관
|
||
_bg_trade_tasks: set = set()
|
||
|
||
|
||
def _spawn_bg_trade_v2(coro) -> "asyncio.Task":
|
||
task = asyncio.create_task(coro)
|
||
_bg_trade_tasks.add(task)
|
||
task.add_done_callback(_bg_trade_tasks.discard)
|
||
return task
|
||
|
||
|
||
@router.get("/rule-based")
|
||
async def get_rule_based_recommendations(
|
||
realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"),
|
||
auto_trade: bool = Query(True, description="자동매매 실행 여부"),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
if realtime:
|
||
result = await get_rule_based_recommendations_realtime()
|
||
else:
|
||
result = await get_rule_based_recommendations_from_db(db)
|
||
|
||
# 대시보드 폴링(GET)이 매매를 트리거하면 스윙 스케줄러와 동시 실행되어 일일 한도가 무력화됨
|
||
# (2026-09-17). 기본적으로 조회 전용이며, API_TRIGGERED_AUTO_TRADE=true 일 때만 실행.
|
||
if auto_trade and settings.API_TRIGGERED_AUTO_TRADE and result.get("recommendations_by_rule"):
|
||
rec_snapshot = result
|
||
async def _bg_trade():
|
||
try:
|
||
async with AsyncSessionLocal() as bg_db:
|
||
await execute_auto_trade_background(rec_snapshot, bg_db)
|
||
except Exception as e:
|
||
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
|
||
_spawn_bg_trade_v2(_bg_trade())
|
||
|
||
# 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지.
|
||
if not realtime:
|
||
try:
|
||
return await _overlay_realtime_v2(result)
|
||
except Exception as e:
|
||
logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}")
|
||
return result
|
||
|
||
def _merge_json_mean_reversion_rule_v2(rules: list) -> list:
|
||
"""DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다.
|
||
|
||
운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가
|
||
한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그
|
||
`MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다.
|
||
플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환.
|
||
"""
|
||
if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False):
|
||
return rules
|
||
if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules):
|
||
return rules
|
||
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)
|
||
mr = [r for r in config.get("rules", [])
|
||
if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"]
|
||
except Exception as e:
|
||
logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}")
|
||
return rules
|
||
if not mr:
|
||
return rules
|
||
r = mr[0]
|
||
next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1
|
||
return rules + [Rule(
|
||
id=next_id, name=r["name"], description=r.get("description", ""),
|
||
rule_type="MEAN_REVERSION", priority=r.get("priority", 1),
|
||
conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True,
|
||
)]
|
||
|
||
|
||
async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
||
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:
|
||
rules = [
|
||
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
|
||
]
|
||
return _merge_json_mean_reversion_rule_v2(rules)
|
||
except Exception as e:
|
||
logger.warning(f"trading_rules DB 조회 실패: {e}")
|
||
|
||
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:
|
||
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}")
|
||
|
||
return [
|
||
Rule(
|
||
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙",
|
||
rule_type="MEAN_REVERSION", priority=1,
|
||
conditions={"rsi_bull_max": 40, "rsi_bear_max": 30,
|
||
"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 _mean_reversion_rsi_thresholds_v2(conditions: dict) -> tuple:
|
||
"""추천 스캐너 평균회귀 RSI 임계 (bull, bear, 출처).
|
||
|
||
[C1 2026-10-02] 플래그 `MEAN_REVERSION_CANONICAL_PARAMS_ENABLED`
|
||
- False(기본, 현행 동작 불변): trading_rules conditions 의
|
||
`rsi_bull_max`/`rsi_bear_max` — DB `trading_rules` → `trading_rules_config.json`
|
||
→ `_load_active_rules_from_db` 하드코딩 폴백 순. 키가 없으면 40/30.
|
||
⚠️ 즉 CLAUDE.md §6.2 의 46/35 와 다르다(§0 우선순위가 이 경로에는 적용되지 않음).
|
||
- True: CLAUDE.md §0 우선순위 — adaptive(`rsi_entry`/`bear_rsi_entry`, fallback_used=False 일
|
||
때) → strategy_cfg(`MEAN_REVERSION_RSI_THRESHOLD`/`_BEAR_RSI_THRESHOLD`) → settings.
|
||
mean_reversion_strategy.check_buy_signal 과 같은 출처를 쓴다. 비교식(rsi > 임계 → 탈락,
|
||
즉 `rsi <= 임계` 통과)·RSI 계산 방식·나머지 필터는 바꾸지 않는다.
|
||
"""
|
||
if not settings.MEAN_REVERSION_CANONICAL_PARAMS_ENABLED:
|
||
return conditions.get("rsi_bull_max", 40), conditions.get("rsi_bear_max", 30), "trading_rules"
|
||
|
||
bull = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
|
||
bear = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
|
||
source = "settings"
|
||
try:
|
||
from app.services.strategy_config_service import strategy_cfg
|
||
_b = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
|
||
_r = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
|
||
if _b is not None:
|
||
bull, source = float(_b), "strategy_cfg"
|
||
if _r is not None:
|
||
bear, source = float(_r), "strategy_cfg"
|
||
except Exception as e:
|
||
logger.warning(f"[MR_SCAN] strategy_cfg RSI 임계 조회 실패 — settings 값 사용: {e}")
|
||
try:
|
||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||
_adp = get_current_adaptive_params()
|
||
if not _adp.fallback_used:
|
||
bull, bear, source = float(_adp.rsi_entry), float(_adp.bear_rsi_entry), "adaptive"
|
||
except Exception as e:
|
||
logger.warning(f"[MR_SCAN] 적응형 RSI 임계 조회 실패 — {source} 값 사용: {e}")
|
||
return bull, bear, source
|
||
|
||
|
||
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
|
||
"""추천 스캐너의 평균회귀 후보 판정 — **실제 스윙 평균회귀 진입의 1차 게이트**.
|
||
|
||
현재 실제 진입 기준 (2026-10-02 기준, 이 함수 기준):
|
||
- 공통: RSI·종가·BB(상/중/하) 모두 존재, 종가 ≥ MIN_PRICE_KRW,
|
||
own_trend 게이트(MA20 < MA60×0.97 이면 탈락, MA 없으면 미적용),
|
||
ADX(14) > 30 이면 탈락(강추세 — 100 초과는 계산오류로 무시)
|
||
- RSI 는 StockAnalysis.rsi(배치 산출값)를 그대로 쓴다. 거래량 하한·RSI 반등 조건은
|
||
**이 함수에 없다**(후속 execute_auto_trade 경로의 필터에 의존).
|
||
- BULL: RSI ≤ bull 임계, %B ≤ 0.35(RSI<25) / 0.30(RSI<30) / 0.20(그 외)
|
||
- BEAR: RSI ≤ bear 임계, %B ≤ 0.30(RSI<20) / 0.25(RSI<25) / 0.20(그 외)
|
||
- 임계 출처는 `_mean_reversion_rsi_thresholds_v2` 참고(기본: trading_rules 40/30).
|
||
"""
|
||
rsi_val = float(stock.rsi) if stock.rsi else None
|
||
close = int(stock.current_price) if stock.current_price else 0
|
||
bb_lower = float(stock.bollinger_lower) if stock.bollinger_lower else None
|
||
bb_upper = float(stock.bollinger_upper) if stock.bollinger_upper else None
|
||
bb_middle = float(stock.bollinger_middle) if stock.bollinger_middle else None
|
||
|
||
if not all([rsi_val, close, bb_lower, bb_upper, bb_middle]):
|
||
return False
|
||
|
||
if close <= settings.MIN_PRICE_KRW: # CLAUDE.md §5.1 '>5,000원' (정확히 5,000원 제외)
|
||
return False
|
||
|
||
# [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57)
|
||
# 종목 자체 MA20 < MA60*0.97 (하락추세)이면 평균회귀 매수(물타기) 차단.
|
||
# ma20/ma60 미조회(None)인 경우 게이트 미적용 — 기존 동작 보존.
|
||
ma20_val = float(stock.ma20) if getattr(stock, "ma20", None) else None
|
||
ma60_val = float(stock.ma60) if getattr(stock, "ma60", None) else None
|
||
if ma20_val is not None and ma60_val is not None and ma60_val > 0 and ma20_val < ma60_val * 0.97:
|
||
return False
|
||
|
||
regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR
|
||
adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None
|
||
# ADX는 0~100 범위. 100 초과 시 계산 오류이므로 무시
|
||
if adx_val is not None and adx_val > 100:
|
||
adx_val = None
|
||
adx_strong_trend = adx_val is not None and adx_val > 30
|
||
|
||
# %B 계산: 밴드 내 상대 위치 (0=하단, 1=상단)
|
||
bb_width = bb_upper - bb_lower
|
||
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
|
||
|
||
_bull_thr, _bear_thr, _thr_src = _mean_reversion_rsi_thresholds_v2(conditions)
|
||
|
||
if regime == MarketRegime.BULL:
|
||
rsi_threshold = _bull_thr
|
||
if adx_strong_trend:
|
||
return False
|
||
if rsi_val > rsi_threshold:
|
||
return False
|
||
# RSI 깊이에 따른 동적 %B 임계값
|
||
if rsi_val < 25:
|
||
pct_b_max = 0.35 # 깊은 과매도 → BB 조건 완화
|
||
elif rsi_val < 30:
|
||
pct_b_max = 0.30
|
||
else:
|
||
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
|
||
return pct_b <= pct_b_max
|
||
else:
|
||
rsi_threshold = _bear_thr
|
||
if adx_strong_trend:
|
||
return False
|
||
if rsi_val > rsi_threshold:
|
||
return False
|
||
# BEAR: RSI 깊이에 따른 동적 %B (BULL보다 엄격)
|
||
if rsi_val < 20:
|
||
pct_b_max = 0.30
|
||
elif rsi_val < 25:
|
||
pct_b_max = 0.25
|
||
else:
|
||
pct_b_max = 0.20
|
||
return pct_b <= pct_b_max
|
||
|
||
async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||
active_rules = await _load_active_rules_from_db(db)
|
||
|
||
# KST 기준 오늘만 조회 — timedelta(days=1) 제거로 어제 데이터 누적 표시 방지
|
||
today_kst = _today_kst()
|
||
result = await db.execute(
|
||
select(StockAnalysis)
|
||
.where(StockAnalysis.analysis_date == today_kst)
|
||
.order_by(StockAnalysis.score.desc())
|
||
)
|
||
raw_stocks = result.scalars().all()
|
||
seen = set()
|
||
all_analyzed_stocks = []
|
||
for s in raw_stocks:
|
||
if s.stock_code not in seen:
|
||
# 전일 거래량 필터: 20만주 미만 종목 제외 (유동성 확보)
|
||
if s.volume is not None and s.volume < 200_000:
|
||
continue
|
||
seen.add(s.stock_code)
|
||
all_analyzed_stocks.append(s)
|
||
|
||
if not all_analyzed_stocks or len(all_analyzed_stocks) < 1:
|
||
return {
|
||
"total": 0,
|
||
"active_rules": len(active_rules),
|
||
"analyzed_stocks": 0,
|
||
"analyzed_stock_list": [],
|
||
"recommendations": [],
|
||
"recommendations_by_rule": [],
|
||
"rules_applied": [],
|
||
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
|
||
}
|
||
|
||
rule_matches = []
|
||
recommendations_by_rule = []
|
||
stock_to_rules = {}
|
||
|
||
market_regime_info = await check_market_regime()
|
||
current_market_regime = market_regime_info.get("regime_200d", "bear")
|
||
|
||
for rule in active_rules:
|
||
conditions = rule.conditions
|
||
actions = rule.actions
|
||
rule_matched_stocks = []
|
||
|
||
for stock in all_analyzed_stocks:
|
||
matches = True
|
||
if rule.rule_type == "MEAN_REVERSION":
|
||
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime)
|
||
else:
|
||
matches = False
|
||
|
||
if not matches:
|
||
continue
|
||
|
||
rule_matched_stocks.append(stock)
|
||
|
||
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
||
|
||
rule_stocks = []
|
||
for stock in rule_matched_stocks[:10]:
|
||
stock_dict = _stock_analysis_to_dict(stock, rule=rule, actions=actions)
|
||
rule_stocks.append(stock_dict)
|
||
rule_matches.append(stock_dict)
|
||
|
||
if stock.stock_code not in stock_to_rules:
|
||
stock_to_rules[stock.stock_code] = []
|
||
|
||
rule_exists = any(r["rule_id"] == rule.id for r in stock_to_rules[stock.stock_code])
|
||
if not rule_exists:
|
||
stock_to_rules[stock.stock_code].append({
|
||
"rule_id": rule.id,
|
||
"rule_name": rule.name,
|
||
"priority": rule.priority,
|
||
})
|
||
|
||
if rule_stocks:
|
||
recommendations_by_rule.append({
|
||
"rule_id": rule.id,
|
||
"rule_name": rule.name,
|
||
"rule_description": rule.description,
|
||
"rule_type": rule.rule_type,
|
||
"priority": rule.priority,
|
||
"matched_count": len(rule_matched_stocks),
|
||
"stocks": rule_stocks,
|
||
})
|
||
|
||
if not rule_matches and all_analyzed_stocks:
|
||
already_added = set()
|
||
fallback_stocks = []
|
||
for stock in all_analyzed_stocks[:20]:
|
||
if stock.stock_code in already_added:
|
||
continue
|
||
already_added.add(stock.stock_code)
|
||
stock_dict = _stock_analysis_to_dict(stock)
|
||
# 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한
|
||
# 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP).
|
||
# 게이트 판정 예외는 fail-closed(청산 전용).
|
||
try:
|
||
_mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime))
|
||
except Exception as e:
|
||
logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}")
|
||
_mr_ok = False
|
||
stock_dict["_exit_only"] = not _mr_ok
|
||
stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok
|
||
fallback_stocks.append(stock_dict)
|
||
rule_matches.append(stock_dict)
|
||
|
||
if fallback_stocks:
|
||
recommendations_by_rule.append({
|
||
"rule_id": None,
|
||
"rule_name": "고점수 분석 종목",
|
||
"rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
|
||
"rule_type": "ANALYSIS",
|
||
"priority": 99,
|
||
"matched_count": len(fallback_stocks),
|
||
"stocks": fallback_stocks,
|
||
})
|
||
|
||
return {
|
||
"total": len(rule_matches),
|
||
"active_rules": len(active_rules),
|
||
"analyzed_stocks": len(all_analyzed_stocks),
|
||
"analyzed_stock_list": [
|
||
{
|
||
"stock_code": s.stock_code,
|
||
"stock_name": s.stock_name,
|
||
"current_price": int(s.current_price) if s.current_price else 0,
|
||
"change_rate": round(float(s.change_rate), 2) if s.change_rate else 0,
|
||
"rsi": round(float(s.rsi), 2) if s.rsi else None,
|
||
"stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None,
|
||
"signals": s.signals or [],
|
||
"score": s.score or 0,
|
||
"ml_signal": s.ml_signal,
|
||
"ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None,
|
||
"price_source": "batch",
|
||
"block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence),
|
||
"buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)),
|
||
}
|
||
for s in all_analyzed_stocks
|
||
],
|
||
"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],
|
||
# batch_time: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date.
|
||
"batch_time": _batch_time_iso_v2(all_analyzed_stocks),
|
||
"batch_date": (lambda d: d.date() if isinstance(d, datetime) else d)(
|
||
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
|
||
).isoformat(),
|
||
}
|
||
|
||
async def get_rule_based_recommendations_realtime():
|
||
async with AsyncSessionLocal() as _db:
|
||
active_rules = await _load_active_rules_from_db(_db)
|
||
|
||
if not active_rules:
|
||
criteria = RecommendationCriteria(
|
||
use_rsi=True,
|
||
use_moving_average=True
|
||
)
|
||
all_stocks = await get_all_tradable_stocks()
|
||
stocks_to_analyze = all_stocks[:20]
|
||
|
||
batch_size = 5
|
||
delay_between_batches = 0.3
|
||
batch_results = []
|
||
|
||
for i in range(0, len(stocks_to_analyze), batch_size):
|
||
batch = stocks_to_analyze[i:i + batch_size]
|
||
tasks = [analyze_stock(code, name, criteria) for code, name in batch]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
batch_results.extend(results)
|
||
|
||
if i + batch_size < len(stocks_to_analyze):
|
||
await asyncio.sleep(delay_between_batches)
|
||
|
||
valid_recommendations = [
|
||
r for r in batch_results
|
||
if r is not None and not isinstance(r, Exception)
|
||
]
|
||
valid_recommendations.sort(key=lambda x: x.score, reverse=True)
|
||
|
||
default_stocks = []
|
||
for rec in valid_recommendations[:10]:
|
||
stock_dict = {
|
||
"stock_code": rec.stock_code,
|
||
"stock_name": rec.stock_name,
|
||
"current_price": rec.current_price,
|
||
"change_rate": rec.change_rate,
|
||
"signals": rec.signals,
|
||
"score": rec.score,
|
||
"price_date": rec.price_date,
|
||
"rule_id": None,
|
||
"rule_name": "기본 추천",
|
||
"rule_description": "RSI + 이동평균 기반 추천",
|
||
"rule_priority": 1,
|
||
"position_ratio": 0.15,
|
||
"take_profit": 5.0,
|
||
"stop_loss": -6.0
|
||
}
|
||
default_stocks.append(stock_dict)
|
||
|
||
return {
|
||
"total": len(default_stocks),
|
||
"active_rules": 0,
|
||
"analyzed_stocks": len(stocks_to_analyze),
|
||
"recommendations": default_stocks,
|
||
"recommendations_by_rule": [{
|
||
"rule_id": None,
|
||
"rule_name": "기본 추천 (RSI + 이동평균)",
|
||
"rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목",
|
||
"rule_type": "BUY",
|
||
"priority": 1,
|
||
"matched_count": len(valid_recommendations),
|
||
"stocks": default_stocks
|
||
}] if default_stocks else [],
|
||
"rules_applied": [],
|
||
"message": "기본 지표로 분석되었습니다 (규칙 미설정)"
|
||
}
|
||
|
||
all_stocks = await get_all_tradable_stocks()
|
||
stocks_to_analyze = all_stocks[:settings.ANALYSIS_STOCK_COUNT]
|
||
|
||
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)
|
||
except Exception as _e:
|
||
logger.warning(f"포트폴리오 종목 추가 실패 (non-critical): {_e}")
|
||
|
||
criteria = RecommendationCriteria(
|
||
use_rsi=True,
|
||
use_moving_average=True,
|
||
use_macd=True,
|
||
use_bollinger=True,
|
||
use_stochastic=True,
|
||
for_batch=True,
|
||
min_volume_ratio=0, # intraday acml_vol << avg_vol_20 → 장중엔 항상 0으로 고정
|
||
)
|
||
|
||
batch_size = 5 # 네이버 일봉 사용으로 병렬 처리 가능
|
||
delay_between_batches = 0.3 # 네이버 서버 부하 방지 최소 딜레이
|
||
all_analyzed_stocks = []
|
||
|
||
for i in range(0, len(stocks_to_analyze), batch_size):
|
||
batch = stocks_to_analyze[i:i + batch_size]
|
||
tasks = [analyze_stock(code, name, criteria) for code, name in batch]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
all_analyzed_stocks.extend(results)
|
||
|
||
if i + batch_size < len(stocks_to_analyze):
|
||
await asyncio.sleep(delay_between_batches)
|
||
|
||
valid_analyzed_stocks = [
|
||
r for r in all_analyzed_stocks
|
||
if r is not None and not isinstance(r, Exception)
|
||
]
|
||
|
||
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:
|
||
conditions = rule.conditions
|
||
actions = rule.actions
|
||
rule_matched_stocks = []
|
||
|
||
for stock in valid_analyzed_stocks:
|
||
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
|
||
if any(k in stock.stock_name for k in etf_keywords):
|
||
continue
|
||
|
||
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
|
||
else:
|
||
matches = _generic_rule_matches_v2(rule, stock, conditions)
|
||
|
||
if matches:
|
||
rule_matched_stocks.append(stock)
|
||
|
||
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
||
|
||
rule_stocks = []
|
||
for rec in rule_matched_stocks[:10]:
|
||
stock_with_rule = {
|
||
**rec.model_dump(),
|
||
"rule_id": rule.id,
|
||
"rule_name": rule.name,
|
||
"rule_description": rule.description,
|
||
"rule_priority": rule.priority,
|
||
"position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.10),
|
||
"take_profit": actions.get("take_profit", 3.0),
|
||
"stop_loss": actions.get("stop_loss", -1.5),
|
||
"max_position_pct": actions.get("max_position_pct", 0.12),
|
||
"max_add_buys": actions.get("max_add_buys", 3),
|
||
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15)
|
||
}
|
||
rule_stocks.append(stock_with_rule)
|
||
rule_matches.append(stock_with_rule)
|
||
|
||
if rule_stocks:
|
||
recommendations_by_rule.append({
|
||
"rule_id": rule.id,
|
||
"rule_name": rule.name,
|
||
"rule_description": rule.description,
|
||
"rule_type": rule.rule_type,
|
||
"priority": rule.priority,
|
||
"matched_count": len(rule_matched_stocks),
|
||
"stocks": rule_stocks
|
||
})
|
||
|
||
rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"]))
|
||
|
||
# 현재가는 DB 분석 데이터 그대로 사용 (KIS API 미호출)
|
||
top_recommendations = rule_matches[:20]
|
||
|
||
# KST 기준 오늘 날짜 사용 (UTC 기준 date.today() 사용 시 KST와 날짜 불일치 → UPSERT 대신 INSERT 반복 버그 수정)
|
||
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
|
||
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
|
||
_today = _today_kst()
|
||
_items = _persistable_items_v2(
|
||
[(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today
|
||
)
|
||
if _items:
|
||
await _queue_bulk(_items, _today)
|
||
|
||
return {
|
||
"total": len(rule_matches),
|
||
"active_rules": len(active_rules),
|
||
"analyzed_stocks": len(stocks_to_analyze),
|
||
"recommendations": top_recommendations,
|
||
"recommendations_by_rule": recommendations_by_rule,
|
||
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules]
|
||
}
|
||
|
||
@router.post("/auto-trade")
|
||
async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
|
||
"""추천 기반 자동매매 수동 트리거.
|
||
|
||
[H-3 2026-10-02] 기존에는 종목별 `execute_auto_trade()` 를 직접 호출해
|
||
시장 국면(SIDEWAYS/CRASH → max_trades=0)·position_multiplier·일일 매수 한도·
|
||
`_auto_trade_bg_lock`(스윙 스케줄러와의 동시 실행 방지)·`AUTO_TRADE_ENABLED` 를
|
||
전부 우회했다. 이제 스케줄러와 **같은 진입점** `execute_auto_trade_background()` 로
|
||
위임해 동일 락·게이트를 탄다. 게이트 판정은 try 밖(§4).
|
||
"""
|
||
if not settings.AUTO_TRADE_ENABLED:
|
||
raise HTTPException(status_code=423, detail="AUTO_TRADE_ENABLED=False — 자동매매 비활성 상태")
|
||
if settings.TRADING_HALTED:
|
||
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
|
||
from app.services.trade_executor import _auto_trade_bg_lock
|
||
if _auto_trade_bg_lock.locked():
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="자동매매가 이미 실행 중입니다(스윙 스케줄러 등) — 중복 실행 방지",
|
||
)
|
||
|
||
try:
|
||
recommendations_response = await get_rule_based_recommendations(
|
||
realtime=True, auto_trade=False, db=db
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"❌ 자동 매매 추천 조회 오류: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
if not recommendations_response.get("recommendations_by_rule"):
|
||
return {"message": "추천 종목이 없습니다", "trades": []}
|
||
|
||
try:
|
||
results = await execute_auto_trade_background(recommendations_response, db)
|
||
except Exception as e:
|
||
logger.error(f"❌ 자동 매매 실행 오류: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
results = results if isinstance(results, list) else []
|
||
# 반환 리스트에는 SELL·실패건도 섞인다 — 진입 건수는 BUY & success 로 센다(CLAUDE.md §6.4)
|
||
trades = [r for r in results if isinstance(r, dict) and r.get("action") in ("BUY", "SELL")]
|
||
buys = sum(1 for r in trades if r.get("action") == "BUY" and r.get("success"))
|
||
return {
|
||
"message": f"자동 매매 완료: 진입 {buys}건 / 처리 {len(results)}건",
|
||
"trades": trades,
|
||
"total_analyzed": len(results),
|
||
}
|
||
|
||
@router.get("/ai/{stock_code}")
|
||
async def get_ai_recommendation(stock_code: str):
|
||
"""종목 기술적 분석 — AI 미사용, 전략 엔진(RSI/BB/MA/ATR/ADX) 직접 계산."""
|
||
from app.services.market_analyzer import analyze_stock_for_batch
|
||
try:
|
||
rec = await analyze_stock_for_batch(stock_code, stock_code)
|
||
if rec is None:
|
||
raise HTTPException(status_code=404, detail="분석 데이터를 가져올 수 없습니다.")
|
||
|
||
signals = rec.signals or []
|
||
score = rec.score or 0
|
||
|
||
# 전략 신호 기반 요약 문자열 생성
|
||
if score >= 3:
|
||
summary = f"매수 신호 감지 (점수: {score}). 주요 신호: {', '.join(signals[:3])}"
|
||
elif score <= -2:
|
||
summary = f"매도/경고 신호 (점수: {score}). 주요 신호: {', '.join(signals[:3])}"
|
||
else:
|
||
summary = f"보유/관망 (점수: {score}). 신호: {', '.join(signals[:3]) if signals else '없음'}"
|
||
|
||
return {
|
||
"stock_code": stock_code,
|
||
"analysis": summary,
|
||
"signals": signals,
|
||
"score": score,
|
||
"market_data": {
|
||
"current_price": rec.current_price,
|
||
"change_rate": rec.change_rate,
|
||
"volume": rec.volume,
|
||
"rsi": rec.rsi,
|
||
"ma5": rec.ma5,
|
||
"ma20": rec.ma20,
|
||
"ma60": rec.ma60,
|
||
"bollinger_upper": rec.bollinger_upper,
|
||
"bollinger_lower": rec.bollinger_lower,
|
||
"bollinger_middle": rec.bollinger_middle,
|
||
"atr_14": rec.atr_14,
|
||
"adx_14": rec.adx_14,
|
||
"stochastic_k": rec.stochastic_k,
|
||
"stochastic_d": rec.stochastic_d,
|
||
}
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Strategy analysis error for {stock_code}: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|