From 3056e8d35f970eff456dd85ab7978b7fe0ee7173 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 31 May 2026 22:03:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20portfolio=5Fhealth=20(=EC=A7=91?= =?UTF-8?q?=EC=A4=91=EB=8F=84=C2=B7=ED=98=84=EA=B8=88=C2=B7=EC=86=90?= =?UTF-8?q?=EC=9D=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- stock/app/holdings_intel.py | 29 +++++++++++++++++++++++++++++ stock/app/test_holdings_intel.py | 13 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/stock/app/holdings_intel.py b/stock/app/holdings_intel.py index 43b01a3..f760026 100644 --- a/stock/app/holdings_intel.py +++ b/stock/app/holdings_intel.py @@ -209,6 +209,35 @@ def news_issues(tickers: list[str], date: str, use_llm: bool = True) -> dict[str return out +# ---- Task 3.3: portfolio_health ---- + + +def portfolio_health(holdings: list[dict], total_cash: int = 0) -> dict: + """비중 집중도(최대비중·HHI) + 현금비중 + 총손익 요약.""" + evals, buys = [], [] + for h in holdings: + cur = h.get("current_price") or h.get("avg_price") or 0 + ev = cur * h.get("quantity", 0) + bu = (h.get("avg_price") or 0) * h.get("quantity", 0) + evals.append(ev) + buys.append(bu) + total_eval = sum(evals) + total_buy = sum(buys) + weights = [e / total_eval for e in evals] if total_eval else [] + hhi = sum(w * w for w in weights) + total_assets = total_eval + (total_cash or 0) + return { + "positions": len(holdings), + "total_eval": total_eval, + "total_buy": total_buy, + "total_pnl": total_eval - total_buy, + "total_pnl_rate": ((total_eval - total_buy) / total_buy * 100.0) if total_buy else 0.0, + "max_weight": max(weights) if weights else 0.0, + "hhi": round(hhi, 4), + "cash_ratio": ((total_cash or 0) / total_assets) if total_assets else 0.0, + } + + def decide_action(tech_score: float, exit_flags: dict, pnl: float | None, add_score: float = ADD_SCORE) -> tuple[str, str]: """액션 결정 매트릭스: sell > trim > add > hold (우선순위 순). diff --git a/stock/app/test_holdings_intel.py b/stock/app/test_holdings_intel.py index 9227efc..455be8e 100644 --- a/stock/app/test_holdings_intel.py +++ b/stock/app/test_holdings_intel.py @@ -213,3 +213,16 @@ def test_news_issues_flags_negative_sentiment(monkeypatch): assert "005930" in issues assert issues["005930"][0]["type"] == "news" assert issues["005930"][0]["severity"] in ("med", "high") + + +def test_portfolio_health(): + holdings = [ + {"ticker": "005930", "quantity": 10, "avg_price": 70000, "current_price": 77000, + "is_krx": True}, + {"ticker": "000660", "quantity": 5, "avg_price": 100000, "current_price": 90000, + "is_krx": True}, + ] + h = hi.portfolio_health(holdings, total_cash=1000000) + assert h["positions"] == 2 + assert 0 <= h["max_weight"] <= 1.0 + assert "total_eval" in h and "total_pnl" in h and "cash_ratio" in h