diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 7d1d6c8..6985dd1 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -580,6 +580,88 @@ async def get_orderbook(stock_code: str): return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"} +def _suggest_buy_amount( + current_price: int, + recommendation: str, + confidence: float, + stock_code: str, +) -> dict: + """AI 분석 화면에 표시할 매수 추천 금액·수량 산출. + + 실제 자동매매 사이징과 같은 기준을 쓴다(CLAUDE.md §5.1): + · 종목당 상한 = 총자산 × MAX_POSITION_SIZE (적응형 모드면 그 값) + · 현금 여력 = 예수금 − 최소 현금보유(총자산 × (1 − MAX_TOTAL_CAPITAL_USAGE)) + · 스윙 합산 상한(SWING_MAX_CAPITAL_RATIO) 잔여 한도 + · 신뢰도 보정 = 예산 × (confidence / 0.7), 최대 1.2배 + BUY 가 아니거나 잔고 정보를 모르면 금액 0 과 사유를 반환한다. + """ + from app.core.config import settings as _cfg + + out = { + "suggested_amount": 0, + "suggested_quantity": 0, + "position_ratio": 0.0, + "sizing_note": "", + } + if current_price <= 0: + out["sizing_note"] = "현재가 없음" + return out + if recommendation != "BUY": + out["sizing_note"] = f"{recommendation} 신호 — 신규 매수 제안 없음" + return out + + try: + from app.api.endpoints.portfolio import portfolio_cache + summary = portfolio_cache.get_summary_copy() or {} + total_assets = float(summary.get("total_assets", 0) or 0) + available_cash = float(summary.get("available_cash", 0) or 0) + stock_value = float(summary.get("total_value", 0) or 0) + except Exception: + total_assets = available_cash = stock_value = 0.0 + + if total_assets <= 0 or available_cash <= 0: + out["sizing_note"] = "잔고 조회 전 — 금액 계산 불가" + return out + + position_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.06)) + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp = get_current_adaptive_params() + if not _adp.fallback_used: + position_cap = float(_adp.position_size) + except Exception: + pass + + budget = total_assets * position_cap + # 신뢰도 보정 (기준 0.7) + if confidence > 0: + budget *= min(1.2, max(0.5, confidence / 0.7)) + # 현금 여력 + reserve = total_assets * (1 - float(getattr(_cfg, "MAX_TOTAL_CAPITAL_USAGE", 0.95))) + budget = min(budget, max(0.0, available_cash - reserve)) + # 스윙 합산 상한 잔여 + swing_cap = float(getattr(_cfg, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0) + if 0 < swing_cap < 1: + budget = min(budget, max(0.0, total_assets * swing_cap - stock_value)) + + qty = int(budget // current_price) + if qty <= 0: + out["sizing_note"] = "가용 예산이 1주 미만 — 매수 제안 없음" + return out + + amount = qty * current_price + out.update({ + "suggested_amount": amount, + "suggested_quantity": qty, + "position_ratio": round(amount / total_assets * 100, 2), + "sizing_note": ( + f"총자산 {total_assets:,.0f}원 × 종목당 {position_cap * 100:.0f}%" + f"(신뢰도 {confidence * 100:.0f}% 반영) 기준" + ), + }) + return out + + @router.get("/ai-analysis/{stock_code}") async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): """AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)""" @@ -791,6 +873,8 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): "recommendation": recommendation, "confidence": confidence, "trend": trend, + # 매수 추천 금액 (실제 자동매매 사이징과 동일 기준) + **_suggest_buy_amount(current_price, recommendation, confidence, stock_code), "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 new file mode 100644 index 0000000..d96c8de --- /dev/null +++ b/backend/tests/test_ai_analysis_buy_amount.py @@ -0,0 +1,68 @@ +"""AI 투자 분석 — 매수 추천 금액 산출 검증 (자동매매 사이징과 동일 기준).""" +from unittest.mock import patch + +import pytest + +from app.api.endpoints.market_data import _suggest_buy_amount +from app.api.endpoints.portfolio import portfolio_cache + + +def _summary(total_assets=10_000_000, cash=5_000_000, stock_value=3_000_000): + return {"total_assets": total_assets, "available_cash": cash, "total_value": stock_value} + + +@pytest.fixture(autouse=True) +def _cache(): + portfolio_cache.summary = _summary() + yield + portfolio_cache.summary = None + + +class _Adp: + fallback_used = False + position_size = 0.06 + + +def test_buy_amount_uses_position_cap_and_confidence(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): + out = _suggest_buy_amount(current_price=70_000, recommendation="BUY", + confidence=0.7, stock_code="005930") + # 1,000만 × 6% = 60만, 신뢰도 0.7 → 보정 1.0 → 70,000원 × 8주 = 560,000 + assert out["suggested_quantity"] == 8 + assert out["suggested_amount"] == 560_000 + assert out["position_ratio"] == pytest.approx(5.6, abs=0.01) + assert "6%" in out["sizing_note"] + + +def test_high_confidence_scales_up_capped_at_120pct(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): + out = _suggest_buy_amount(70_000, "BUY", 1.0, "005930") + # 60만 × min(1.2, 1.0/0.7=1.43) = 72만 → 10주 + assert out["suggested_quantity"] == 10 + + +def test_non_buy_recommendation_returns_zero(): + for rec in ("HOLD", "SELL"): + out = _suggest_buy_amount(70_000, rec, 0.8, "005930") + assert out["suggested_amount"] == 0 and rec in out["sizing_note"] + + +def test_limited_by_available_cash_and_reserve(): + portfolio_cache.summary = _summary(cash=600_000) # 예수금 60만, 최소보유 5% = 50만 + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): + out = _suggest_buy_amount(70_000, "BUY", 0.7, "005930") + assert out["suggested_quantity"] == 1 # 사용 가능 10만 → 1주 + + +def test_limited_by_swing_capital_cap(): + # 스윙 상한 50% = 500만, 이미 490만 보유 → 잔여 10만 + portfolio_cache.summary = _summary(stock_value=4_900_000) + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): + out = _suggest_buy_amount(70_000, "BUY", 0.7, "005930") + assert out["suggested_quantity"] == 1 + + +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"] diff --git a/frontend/src/components/ChartPanel.tsx b/frontend/src/components/ChartPanel.tsx index 0d83845..81ac8b8 100644 --- a/frontend/src/components/ChartPanel.tsx +++ b/frontend/src/components/ChartPanel.tsx @@ -143,6 +143,35 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr )} + {/* 매수 추천 금액 — 실제 자동매매 사이징과 동일 기준 (총자산×종목당 비중, 신뢰도 반영) */} + {aiAnalysisData?.data && ( +