From f480eac25c08d5a8d7859aa5878e9f0e712593fc Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 13:34:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=ED=88=AC=EC=9E=90=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EC=97=90=20=EB=AA=A9=ED=91=9C=EA=B0=80=C2=B7=EC=86=90?= =?UTF-8?q?=EC=A0=88=EA=B0=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/endpoints/market_data.py | 80 ++++++++++++++++++++ backend/tests/test_ai_analysis_buy_amount.py | 36 +++++++++ frontend/src/components/ChartPanel.tsx | 22 ++++++ 3 files changed, 138 insertions(+) diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 6985dd1..87d96ee 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -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 포함 } diff --git a/backend/tests/test_ai_analysis_buy_amount.py b/backend/tests/test_ai_analysis_buy_amount.py index d96c8de..8710dc5 100644 --- a/backend/tests/test_ai_analysis_buy_amount.py +++ b/backend/tests/test_ai_analysis_buy_amount.py @@ -66,3 +66,39 @@ def test_no_balance_info_reports_reason(): portfolio_cache.summary = None out = _suggest_buy_amount(70_000, "BUY", 0.8, "005930") assert out["suggested_amount"] == 0 and "잔고" in out["sizing_note"] + + +# ── 목표가 · 손절가 ───────────────────────────────────────────────────────── +from app.api.endpoints.market_data import _suggest_targets # noqa: E402 + + +def test_targets_use_tighter_stop_and_2r(): + """ATR 손절(2.5×)과 -7% 중 타이트한 쪽(가격 높은 쪽), 목표는 +2R.""" + with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception): + # ATR 1,000 → ATR손절 100,000-2,500 = 97,500 / -7% = 93,000 → 97,500 채택 + out = _suggest_targets(100_000, atr=1_000.0) + assert out["stop_loss_price"] == 97_500 + assert out["target_price"] == 105_000 # 100,000 + 2×2,500 + assert out["stop_loss_pct"] == pytest.approx(-2.5, abs=0.01) + assert out["target_pct"] == pytest.approx(5.0, abs=0.01) + assert out["risk_reward"] == 2.0 + assert "ATR" in out["target_note"] + + +def test_percent_stop_wins_when_atr_is_wide(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception): + # ATR 5,000 → ATR손절 87,500 < -7% 93,000 → 93,000(타이트) 채택 + out = _suggest_targets(100_000, atr=5_000.0) + assert out["stop_loss_price"] == 93_000 + assert out["target_price"] == 114_000 + assert "-7%" in out["target_note"] + + +def test_targets_without_atr_fall_back_to_percent(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception): + out = _suggest_targets(100_000, atr=None) + assert out["stop_loss_price"] == 93_000 and "ATR 없음" in out["target_note"] + + +def test_targets_zero_price(): + assert _suggest_targets(0, atr=1000.0)["target_price"] == 0 diff --git a/frontend/src/components/ChartPanel.tsx b/frontend/src/components/ChartPanel.tsx index 81ac8b8..2201f3f 100644 --- a/frontend/src/components/ChartPanel.tsx +++ b/frontend/src/components/ChartPanel.tsx @@ -169,6 +169,28 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr {aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
{aiAnalysisData.data.sizing_note}
)} + + {/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */} + {aiAnalysisData.data.target_price > 0 && ( +
+ 목표 + + {Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원 + (+{aiAnalysisData.data.target_pct}%) + + 손절 + + {Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원 + ({aiAnalysisData.data.stop_loss_pct}%) + + + R:R 1:{aiAnalysisData.data.risk_reward} + +
+ )} + {aiAnalysisData.data.target_note && ( +
{aiAnalysisData.data.target_note}
+ )} )}