feat: AI 투자 분석에 목표가·손절가 표시

CLAUDE.md §5.2 기준으로 계산해 매수 추천 금액 아래에 함께 표시.
  · 손절 = ATR(2.5×)과 -7% 중 타이트한 쪽(가격 높은 쪽), 적응형 모드면 그 값 사용
  · 목표 = 진입가 + 2R (R = 진입가 − 손절가) — +2R 50% 분할 매도 지점
ATR 은 stock_analysis.atr_14 우선, 없으면 DB 일봉으로 계산(KIS API 미사용). 테스트 4건 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:34:59 +09:00
parent acbd9f35ea
commit f480eac25c
3 changed files with 138 additions and 0 deletions

View File

@@ -580,6 +580,64 @@ async def get_orderbook(stock_code: str):
return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"}
def _suggest_targets(current_price: int, atr: float | None) -> dict:
"""AI 분석 화면용 목표가·손절가 (CLAUDE.md §5.2 스윙 기준).
· 손절 = max(진입가 − ATR_STOP_MULTIPLIER×ATR, 진입가×(1−STOP_LOSS_PERCENT))
→ 둘 중 '타이트한 쪽'(가격이 높은 쪽). ATR 없으면 퍼센트 기준만 사용.
· 목표 = 진입가 + 2R (R = 진입가 − 손절가) → +2R 도달 시 50% 분할 매도 지점
적응형 모드가 활성화되어 있으면 그 손절률·ATR 배수를 따른다.
"""
from app.core.config import settings as _cfg
out = {
"target_price": 0,
"stop_loss_price": 0,
"target_pct": 0.0,
"stop_loss_pct": 0.0,
"risk_reward": 0.0,
"target_note": "",
}
if current_price <= 0:
return out
stop_pct = abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
atr_mult = float(getattr(_cfg, "ATR_STOP_MULTIPLIER", 2.5))
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
stop_pct = abs(float(_adp.stop_loss_pct))
atr_mult = float(_adp.atr_stop_multiplier)
except Exception:
pass
pct_stop = current_price * (1 - stop_pct / 100)
if atr and atr > 0:
atr_stop = current_price - atr_mult * atr
stop = max(atr_stop, pct_stop) # 타이트한 쪽 (CLAUDE.md 8.1-5: 항상 min 아닌 max=높은 가격)
basis = "ATR" if atr_stop > pct_stop else f"-{stop_pct:.0f}%"
note = f"손절 {basis} 기준(타이트한 쪽), 목표 +2R"
else:
stop = pct_stop
note = f"손절 -{stop_pct:.0f}% 기준(ATR 없음), 목표 +2R"
risk = current_price - stop
if risk <= 0:
return out
target = current_price + 2 * risk
out.update({
"target_price": int(round(target)),
"stop_loss_price": int(round(stop)),
"target_pct": round((target / current_price - 1) * 100, 2),
"stop_loss_pct": round((stop / current_price - 1) * 100, 2),
"risk_reward": 2.0,
"target_note": note,
})
return out
def _suggest_buy_amount(
current_price: int,
recommendation: str,
@@ -693,6 +751,12 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
analysis_record = result.scalar_one_or_none()
indicators = {}
_atr_for_targets = None
if analysis_record and getattr(analysis_record, "atr_14", None):
try:
_atr_for_targets = float(analysis_record.atr_14)
except Exception:
_atr_for_targets = None
if analysis_record:
indicators = {
"rsi": float(analysis_record.rsi) if analysis_record.rsi else None,
@@ -852,6 +916,20 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
else:
analysis_text = "기술적 지표 데이터 없음"
# 목표가·손절가용 ATR — 분석 레코드에 없으면 DB 일봉으로 계산
if _atr_for_targets is None:
try:
from app.services.mean_reversion_strategy import calculate_atr
_db_chart = await _fetch_chart_from_db(stock_code, 30)
if len(_db_chart) >= 15:
_atr_for_targets = calculate_atr(
[c["high"] for c in _db_chart],
[c["low"] for c in _db_chart],
[c["close"] for c in _db_chart],
)
except Exception as _atr_err:
logger.debug(f"[AI분석] ATR 계산 실패 ({stock_code}): {_atr_err}")
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
if trend == "SIDEWAYS" and high_price and low_price and current_price:
daily_change_pct = ((current_price - low_price) / low_price) * 100 if low_price > 0 else 0
@@ -875,6 +953,8 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
"trend": trend,
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
# 목표가·손절가 (CLAUDE.md §5.2)
**_suggest_targets(current_price, _atr_for_targets),
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
}