Compare commits
2 Commits
ba900c1bdb
...
7c94bda6e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c94bda6e1 | |||
| f480eac25c |
@@ -580,6 +580,64 @@ 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_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(
|
def _suggest_buy_amount(
|
||||||
current_price: int,
|
current_price: int,
|
||||||
recommendation: str,
|
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()
|
analysis_record = result.scalar_one_or_none()
|
||||||
|
|
||||||
indicators = {}
|
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:
|
if analysis_record:
|
||||||
indicators = {
|
indicators = {
|
||||||
"rsi": float(analysis_record.rsi) if analysis_record.rsi else None,
|
"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:
|
else:
|
||||||
analysis_text = "기술적 지표 데이터 없음"
|
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:
|
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
|
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,
|
"trend": trend,
|
||||||
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
|
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
|
||||||
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
|
**_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 포함
|
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,39 @@ def test_no_balance_info_reports_reason():
|
|||||||
portfolio_cache.summary = None
|
portfolio_cache.summary = None
|
||||||
out = _suggest_buy_amount(70_000, "BUY", 0.8, "005930")
|
out = _suggest_buy_amount(70_000, "BUY", 0.8, "005930")
|
||||||
assert out["suggested_amount"] == 0 and "잔고" in out["sizing_note"]
|
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
|
||||||
|
|||||||
@@ -169,6 +169,28 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
|
|||||||
{aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
{aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
||||||
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
|
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */}
|
||||||
|
{aiAnalysisData.data.target_price > 0 && (
|
||||||
|
<div className="flex items-baseline gap-3 mt-1">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">목표</span>
|
||||||
|
<span className="text-[11px] font-mono text-[#ef4444]">
|
||||||
|
{Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원
|
||||||
|
<span className="text-[9px] ml-1">(+{aiAnalysisData.data.target_pct}%)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">손절</span>
|
||||||
|
<span className="text-[11px] font-mono text-[#3b82f6]">
|
||||||
|
{Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원
|
||||||
|
<span className="text-[9px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
|
||||||
|
R:R 1:{aiAnalysisData.data.risk_reward}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aiAnalysisData.data.target_note && (
|
||||||
|
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.target_note}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user