From 2e26791d86a0718347dc0c89fbbe14dd59b31864 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 16 Sep 2026 13:35:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=88=98=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EC=8B=A4=EC=A0=81=C2=B7=EC=A0=84=EB=A7=9D?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20(Phase=201=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매수 시점에 "왜 샀는지"를 기술적 신호뿐 아니라 뉴스·실적·전망까지 담아 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 Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ --- backend/app/services/naver_finance.py | 48 ++++++++++++++++++++++++++ backend/app/services/trade_executor.py | 38 +++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/backend/app/services/naver_finance.py b/backend/app/services/naver_finance.py index 4c2ade3..2eef2ef 100644 --- a/backend/app/services/naver_finance.py +++ b/backend/app/services/naver_finance.py @@ -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 diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 91f23d3..0d34493 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -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")