feat: 매수 스토리에 실적·전망 통합 (Phase 1 — 기록 전용)

매수 시점에 "왜 샀는지"를 기술적 신호뿐 아니라 뉴스·실적·전망까지 담아
entry_reason에 기록. 매매 판단은 바꾸지 않음(기록 전용).

- naver_finance.get_stock_fundamentals(): 네이버 m.stock integration API로
  PER/EPS/목표주가/투자의견 조회. 10분 TTL 캐시, 실패/타임아웃 시 None.
- trade_executor._execute_buy: 주문 접수 후(주문 지연·차단 없음) 기술+뉴스+
  실적+전망을 한 스토리로 조립해 entry_reason 기록. 각 부분 try/except로
  감싸 실패 시 기술 스토리로 폴백(entry_reason 항상 세팅, 매매 영향 0).

예: "기술: RSI과매도, BB하단 (점수 5) | 실적: PER 11.3배, EPS 22,292원
     | 전망: 목표가 487,045(+93%), 투자의견 4.0/5"

Phase 2(실적을 매매 기준에 반영)는 OpenDART point-in-time 데이터 + 백테스트
검증 후 별도 진행 예정(CLAUDE.md §8.1-4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
This commit is contained in:
2026-09-16 13:35:46 +09:00
parent 8a8e3700db
commit 2e26791d86
2 changed files with 85 additions and 1 deletions

View File

@@ -369,3 +369,51 @@ async def get_vix_index() -> Optional[Dict]:
except Exception as e:
logger.warning("VIX 조회 실패: %s", e)
return None
# ── 종목 펀더멘털(실적·컨센서스·목표주가) — 매수 스토리 기록용 (Phase 1) ────────────
_FUNDAMENTALS_CACHE: Dict[str, Tuple[float, Optional[Dict]]] = {}
_FUNDAMENTALS_TTL = 600 # 10분 캐시
async def get_stock_fundamentals(stock_code: str) -> Optional[Dict]:
"""네이버 m.stock integration API로 실적·컨센서스·목표주가 조회.
매수 시점 "스토리" 기록용(매매 판단엔 미사용 — Phase 1). 실패/타임아웃 시 None
(호출측에서 기술 스토리로 폴백). 10분 TTL 캐시.
Returns:
{"per","eps","target_price","recomm_mean"} 중 확보된 값. 없으면 None.
"""
import time as _t
_now = _t.monotonic()
_c = _FUNDAMENTALS_CACHE.get(stock_code)
if _c and _now - _c[0] < _FUNDAMENTALS_TTL:
return _c[1]
try:
url = f"https://m.stock.naver.com/api/stock/{stock_code}/integration"
async with httpx.AsyncClient(verify=True, timeout=5.0, headers=_HEADERS) as client:
resp = await client.get(url)
if resp.status_code != 200:
_FUNDAMENTALS_CACHE[stock_code] = (_now, None)
return None
data = resp.json()
out: Dict = {}
for it in (data.get("totalInfos") or []):
k = it.get("key")
if k == "PER":
out["per"] = it.get("value")
elif k == "EPS":
out["eps"] = it.get("value")
cons = data.get("consensusInfo") or {}
if cons.get("priceTargetMean"):
out["target_price"] = cons.get("priceTargetMean")
if cons.get("recommMean"):
out["recomm_mean"] = cons.get("recommMean")
result = out or None
_FUNDAMENTALS_CACHE[stock_code] = (_now, result)
return result
except Exception as e:
logger.debug("[FUNDAMENTALS] %s 조회 실패: %s", stock_code, e)
_FUNDAMENTALS_CACHE[stock_code] = (_now, None)
return None

View File

@@ -769,7 +769,43 @@ async def _execute_buy(
)
total_amount = Decimal(str(order_price)) * quantity
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
# ── 매수 스토리 구성: 기술 + 뉴스 + 실적 + 전망 (Phase 1: 기록 전용) ──────
# 주문은 이미 위에서 접수됐으므로 여기서의 펀더멘털 조회는 주문을 지연·차단하지
# 않는다(실패/타임아웃 시 기술 스토리로 폴백). 매매 판단엔 미사용.
_story = [f"기술: {', '.join(signals[:3])} (점수 {score_val})"]
try:
_ns = stock.get("_news_position_scale")
if _ns is not None and float(_ns) < 1.0:
_story.append(f"뉴스: 악재 감지 → 비중 {float(_ns):.0%}")
except Exception:
pass
if is_domestic:
try:
from app.services.naver_finance import get_stock_fundamentals
_fd = await get_stock_fundamentals(stock_code)
if _fd:
_val = []
if _fd.get("per"):
_val.append(f"PER {_fd['per']}")
if _fd.get("eps"):
_val.append(f"EPS {_fd['eps']}")
if _val:
_story.append("실적: " + ", ".join(_val))
_tp = _fd.get("target_price")
if _tp:
try:
_tpv = float(str(_tp).replace(",", ""))
_up = (_tpv / current_price - 1) * 100 if current_price else 0.0
_outlook = f"전망: 목표가 {_tp}({_up:+.0f}%)"
if _fd.get("recomm_mean"):
_outlook += f", 투자의견 {_fd['recomm_mean']}/5"
_story.append(_outlook)
except Exception:
pass
except Exception as _story_err:
logger.debug(f"[STORY] {stock_code} 펀더멘털 스킵: {_story_err}")
entry_reason = " | ".join(_story)
success = order_result.get("rt_cd") == "0" # KIS 성공 코드
is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인)
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")