"""보유종목 인텔리전스 — 순수연산 중심 (advisory). KIS 실주문 미사용.""" from __future__ import annotations import datetime as dt from typing import Any, Optional import pandas as pd from . import db from . import price_fetcher def _krx_tickers() -> set: """krx_master ticker 집합 (KRX 판별용).""" return db.get_krx_tickers() def get_holdings() -> list[dict]: """portfolio + 현재가 + pnl_rate + is_krx.""" items = db.get_all_portfolio() tickers = [it["ticker"] for it in items] prices = price_fetcher.get_current_prices(tickers) if tickers else {} krx = _krx_tickers() out = [] for it in items: cur = prices.get(it["ticker"]) avg = it["avg_price"] pnl = ((cur - avg) / avg * 100.0) if (cur and avg) else None out.append({ **it, "current_price": cur, "pnl_rate": pnl, "is_krx": it["ticker"] in krx, }) return out # ---- Task 2.1: technical_posture ---- from .screener.engine import combine def _score_nodes_and_weights(): """NODE_REGISTRY에서 보유종목 매수강도 계산용 노드 인스턴스화.""" from .screener.registry import NODE_REGISTRY weights = {"ma_alignment": 0.4, "momentum": 0.3, "rs_rating": 0.3} nodes = [NODE_REGISTRY[k]() for k in weights] return nodes, weights def technical_posture(ctx, tickers: list[str]) -> dict[str, float]: """보유종목 restrict 후 score 노드 → 매수강도(0~100).""" scoped = ctx.restrict(tickers) if scoped.prices.empty: return {} nodes, weights = _score_nodes_and_weights() scores = {} for n in nodes: try: scores[n.name] = n.compute(scoped, {}) except Exception: scores[n.name] = pd.Series(0.0, index=scoped.master.index) total = combine(scores, weights) return {t: float(total.get(t, 0.0)) for t in tickers if t in total.index} # ---- Task 2.2: exit_rules ---- def _ma(closes: "pd.Series", window: int) -> Optional[float]: if len(closes) < window: return None return float(closes.rolling(window).mean().iloc[-1]) def exit_rules(holding: dict, ticker_prices: "pd.DataFrame", params: dict) -> dict: """가격 기반 청산/리스크 flag. (momentum_loss는 compute 단계에서 합산.)""" flags = {"stop_loss": False, "ma50_break": False, "ma200_break": False, "take_profit": False, "climax": False} avg = holding.get("avg_price") cur = holding.get("current_price") if ticker_prices is None or ticker_prices.empty: closes = pd.Series(dtype=float) else: closes = ticker_prices.sort_values("date")["close"].astype(float).reset_index(drop=True) last_close = float(closes.iloc[-1]) if len(closes) else cur if cur is None: cur = last_close if cur and avg: if cur < avg * (1 - params["stop_pct"]): flags["stop_loss"] = True if (cur - avg) / avg >= params["take_pct"]: flags["take_profit"] = True ma50 = _ma(closes, 50) ma200 = _ma(closes, 200) if ma50 is not None and last_close is not None and last_close < ma50: flags["ma50_break"] = True if ma200 is not None and last_close is not None and last_close < ma200: flags["ma200_break"] = True # climax: 최근 거래량이 20일 평균의 climax_vol_x배 이상 + 종가가 당일 고점 대비 하단(상단꼬리) if ticker_prices is not None and not ticker_prices.empty and len(ticker_prices) >= 21: tp = ticker_prices.sort_values("date") vol = tp["volume"].astype(float).reset_index(drop=True) avg_vol = vol.iloc[-21:-1].mean() last_vol = vol.iloc[-1] hi_ = float(tp["high"].astype(float).iloc[-1]) cl_ = float(tp["close"].astype(float).iloc[-1]) if avg_vol and last_vol >= avg_vol * params["climax_vol_x"] and hi_ > 0 and cl_ < hi_ * 0.97: flags["climax"] = True return flags # ---- Task 2.3: decide_action ---- ADD_SCORE = 70.0 # 이 이상이면 추가매수 후보 def decide_action(tech_score: float, exit_flags: dict, pnl: float | None) -> tuple[str, str]: """우선순위: sell > trim > add > hold. 근거 텍스트 동봉.""" reasons = [] # 청산 (최우선) if exit_flags.get("stop_loss"): reasons.append("손절선 이탈") if exit_flags.get("ma200_break"): reasons.append("MA200 이탈") if reasons: return "sell", " · ".join(reasons) # 축소 if exit_flags.get("ma50_break"): reasons.append("MA50 이탈") if exit_flags.get("momentum_loss"): reasons.append("모멘텀 소멸") if exit_flags.get("take_profit"): reasons.append(f"목표 수익 도달(+{pnl:.0f}%)" if pnl is not None else "목표 수익 도달") if exit_flags.get("climax"): reasons.append("거래량 급증 분산 의심") if reasons: return "trim", " · ".join(reasons) # 추가매수 if tech_score is not None and tech_score >= ADD_SCORE: return "add", f"기술적 강도 양호({tech_score:.0f})" return "hold", "특이 신호 없음"