144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
"""보유종목 인텔리전스 — 순수연산 중심 (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 (stop_loss/ma50_break/ma200_break/take_profit/climax).
|
|
|
|
Note: momentum_loss는 compute_and_store 단계에서 집계하므로 여기서 설정하지 않는다.
|
|
"""
|
|
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", "특이 신호 없음"
|