feat: AI 투자 분석에 매수 추천 금액 표시
BUY 신호일 때 실제 자동매매와 동일한 기준으로 매수 금액·수량을 계산해 패널 상단에 표시. · 종목당 상한 = 총자산 × MAX_POSITION_SIZE(적응형 모드면 그 값) · 신뢰도 보정 = 예산 × (confidence/0.7), 0.5~1.2배 · 현금 여력(최소 현금보유 제외)·스윙 합산 상한(SWING_MAX_CAPITAL_RATIO) 잔여로 클램프 BUY 가 아니거나 예산이 1주 미만이면 금액 대신 사유를 표시. 테스트 6건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -580,6 +580,88 @@ async def get_orderbook(stock_code: str):
|
|||||||
return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"}
|
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}")
|
@router.get("/ai-analysis/{stock_code}")
|
||||||
async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)"""
|
"""AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)"""
|
||||||
@@ -791,6 +873,8 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
|||||||
"recommendation": recommendation,
|
"recommendation": recommendation,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
|
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
|
||||||
|
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
|
||||||
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
|
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
backend/tests/test_ai_analysis_buy_amount.py
Normal file
68
backend/tests/test_ai_analysis_buy_amount.py
Normal file
@@ -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"]
|
||||||
@@ -143,6 +143,35 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 매수 추천 금액 — 실제 자동매매 사이징과 동일 기준 (총자산×종목당 비중, 신뢰도 반영) */}
|
||||||
|
{aiAnalysisData?.data && (
|
||||||
|
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#0a1120] flex-shrink-0">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">매수 추천</span>
|
||||||
|
{aiAnalysisData.data.suggested_quantity > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[13px] font-mono text-[#22c55e]">
|
||||||
|
{Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-[#8faabf]">
|
||||||
|
{aiAnalysisData.data.suggested_quantity}주
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
|
||||||
|
총자산 {aiAnalysisData.data.position_ratio}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-[#4a6580]">
|
||||||
|
{aiAnalysisData.data.sizing_note || '매수 제안 없음'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
||||||
|
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-3">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-3">
|
||||||
{aiAnalysisText ? (
|
{aiAnalysisText ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user