feat(stock): compute_and_store + build_holdings_brief
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -238,6 +238,80 @@ def portfolio_health(holdings: list[dict], total_cash: int = 0) -> dict:
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_PARAMS = {
|
||||
"stop_pct": 0.08, "take_pct": 0.25, "climax_vol_x": 3.0,
|
||||
"move_pct": 7.0, "vol_z": 2.5,
|
||||
"momentum_drop": 15.0, "momentum_low": 35.0,
|
||||
}
|
||||
|
||||
|
||||
def _load_ctx(asof: dt.date):
|
||||
"""ScreenContext.load를 감싸는 thin wrapper (테스트에서 monkeypatch 대상)."""
|
||||
from .screener.engine import ScreenContext
|
||||
with db._conn() as conn:
|
||||
return ScreenContext.load(conn, asof)
|
||||
|
||||
|
||||
def _today_kst() -> dt.date:
|
||||
return (dt.datetime.utcnow() + dt.timedelta(hours=9)).date()
|
||||
|
||||
|
||||
def compute_and_store(asof: Optional[dt.date] = None, use_llm: bool = True,
|
||||
params: dict | None = None) -> dict:
|
||||
"""보유종목 시그널 계산 → holdings_signals upsert (멱등).
|
||||
|
||||
Returns:
|
||||
{"stored": N, "date": "YYYY-MM-DD"} or {"stored": 0, "reason": "..."}
|
||||
"""
|
||||
asof = asof or _today_kst()
|
||||
p = {**DEFAULT_PARAMS, **(params or {})}
|
||||
holdings = get_holdings()
|
||||
if not holdings:
|
||||
return {"stored": 0, "reason": "no_holdings"}
|
||||
krx = [h for h in holdings if h.get("is_krx")]
|
||||
ctx = _load_ctx(asof)
|
||||
posture = technical_posture(ctx, [h["ticker"] for h in krx]) if krx else {}
|
||||
date_iso = asof.isoformat()
|
||||
issues_map = news_issues([h["ticker"] for h in holdings], date_iso, use_llm=use_llm)
|
||||
stored = 0
|
||||
for h in holdings:
|
||||
t = h["ticker"]
|
||||
tp = ctx.prices[ctx.prices["ticker"] == t] if h.get("is_krx") else None
|
||||
tf = ctx.flow[ctx.flow["ticker"] == t] if h.get("is_krx") else None
|
||||
flags = exit_rules(h, tp, p) if h.get("is_krx") else {}
|
||||
tech = posture.get(t)
|
||||
# momentum_loss: 직전 저장 시그널 대비 하락 or 낮은 강도
|
||||
prev = db.get_holdings_signal_history(t, limit=2)
|
||||
prev_score = next((r["tech_score"] for r in prev if r["date"] != date_iso), None)
|
||||
if tech is not None and (
|
||||
(prev_score is not None and tech < prev_score - p["momentum_drop"])
|
||||
or tech < p["momentum_low"]
|
||||
):
|
||||
flags["momentum_loss"] = True
|
||||
evts = market_events(t, tp, tf, p) if h.get("is_krx") else []
|
||||
issues = list(issues_map.get(t, [])) + evts
|
||||
action, reasons = decide_action(tech if tech is not None else 0.0, flags, h.get("pnl_rate"))
|
||||
db.upsert_holdings_signal(
|
||||
date=date_iso, ticker=t, name=h.get("name"), action=action,
|
||||
tech_score=tech, exit_flags=flags, issues=issues,
|
||||
close=h.get("current_price"), pnl_rate=h.get("pnl_rate"), reasons=reasons,
|
||||
)
|
||||
stored += 1
|
||||
return {"stored": stored, "date": date_iso}
|
||||
|
||||
|
||||
def build_holdings_brief(date: Optional[str] = None) -> dict:
|
||||
"""최신 시그널 + 포트 건강 조립 (브리핑/UI payload)."""
|
||||
date = date or db.get_latest_holdings_date()
|
||||
if not date:
|
||||
return {"date": None, "holdings": [], "portfolio_health": {}}
|
||||
signals = db.get_holdings_signals(date)
|
||||
holdings = get_holdings()
|
||||
total_cash = sum(c.get("cash", 0) for c in db.get_all_broker_cash())
|
||||
health = portfolio_health(holdings, total_cash=total_cash)
|
||||
return {"date": date, "holdings": signals, "portfolio_health": health}
|
||||
|
||||
|
||||
def decide_action(tech_score: float, exit_flags: dict, pnl: float | None,
|
||||
add_score: float = ADD_SCORE) -> tuple[str, str]:
|
||||
"""액션 결정 매트릭스: sell > trim > add > hold (우선순위 순).
|
||||
|
||||
Reference in New Issue
Block a user