하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
784 lines
33 KiB
Python
784 lines
33 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 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()
|
||
|
||
|
||
# 매매 규칙 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": 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
|
||
|
||
@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]
|
||
}
|
||
|
||
@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)
|
||
asyncio.create_task(_bg_trade())
|
||
|
||
return result
|
||
|
||
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:
|
||
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}")
|
||
|
||
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:
|
||
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)
|
||
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,
|
||
}
|
||
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": (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:
|
||
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 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 = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
|
||
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))
|