from __future__ import annotations import math from datetime import date, timedelta from typing import Any # 매도 결정에 동원하는 5개 독립 팩터군. 단일 팩터의 임계값 돌파만으로는 행동을 # 트리거하지 않는다 — 최소 CONFLUENCE_MIN개 팩터군이 동일 방향으로 합의해야 # SELL/ADD 확신도가 성립한다. (기계적 단일 트리거 매도 금지 원칙) FACTOR_FAMILIES: tuple[str, ...] = ( "macro_pressure", "fundamental_trajectory", "short_interest_pressure", "microstructure_pressure", "liquidity_rotation_risk", ) CONFLUENCE_MIN = 3 EVENT_PRE_GUARD_DAYS = 5 # macro_event_synchronizer_v2.event_hold_gate와 동일 — HIGH 이벤트 5일 전 EVENT_POST_GUARD_DAYS = 2 # 이벤트 후 2일 변동성 소화 구간 # 금리국면별 시장 성격: 금리 상승기=실적장세(펀더멘털/수출입 실적이 가격을 주도), # 금리 보합·하락기=기술장세(수급·미시구조가 가격을 주도). 동일한 5팩터라도 # 국면에 따라 가중치를 달리 줘야 confluence가 의미를 갖는다. REGIME_FLAT_WEIGHTS: dict[str, float] = {family: 1.0 for family in FACTOR_FAMILIES} REGIME_WEIGHT_TABLE: dict[str, dict[str, float]] = { "PERFORMANCE_MARKET": { # 금리 상승기 — 실적/수출입 펀더멘털 가중 상향 "macro_pressure": 1.2, "fundamental_trajectory": 1.8, "short_interest_pressure": 1.0, "microstructure_pressure": 0.5, "liquidity_rotation_risk": 1.0, }, "TECHNICAL_MARKET": { # 금리 보합·하락기 — 수급/미시구조 가중 상향 "macro_pressure": 0.8, "fundamental_trajectory": 0.8, "short_interest_pressure": 1.3, "microstructure_pressure": 1.6, "liquidity_rotation_risk": 1.3, }, "NEUTRAL": REGIME_FLAT_WEIGHTS, } def classify_market_regime(rate_trend: str | None) -> str: """금리 추세 문자열(RISING/FLAT/FALLING)을 실적장세/기술장세로 분류. RISING → PERFORMANCE_MARKET(실적장세): 금리 상승기엔 유동성보다 실적/펀더멘털이 가격을 결정. FLAT/FALLING → TECHNICAL_MARKET(기술장세): 유동성이 풍부해 수급· 미시구조·테마성 모멘텀이 가격을 주도. 입력 결측 시 NEUTRAL(가중치 변화 없음). """ trend = str(rate_trend or "").upper() if trend == "RISING": return "PERFORMANCE_MARKET" if trend in {"FLAT", "FALLING"}: return "TECHNICAL_MARKET" return "NEUTRAL" def _finite(value: Any) -> bool: return isinstance(value, (int, float)) and math.isfinite(float(value)) def compute_short_interest_composite(ctx: dict[str, Any]) -> dict[str, Any]: """SHORT_INTEREST_RISK_GAUGE_V1. 5요소: 공매도잔고율 변화, 공매도거래비중, 상대수익률(섹터/지수 대비), 거래량 이상, 실적전망. 잔고율 단독으로는 매도 근거가 약함(현대로템형) — 잔고율이 낮을 때는 거래비중·상대수익률 가중치를 자동 상향한다. """ missing: list[str] = [] short_balance_ratio = ctx.get("short_balance_ratio") # %, 현재 잔고율 short_balance_ratio_chg_20d = ctx.get("short_balance_ratio_chg_20d") # %p, 20일 변화 short_turnover_share = ctx.get("short_turnover_share") # 당일 거래 중 공매도 비중 % relative_return_20d = ctx.get("relative_return_20d") # 종목수익률 - 섹터(or지수)수익률, %p volume_ratio_5d = ctx.get("volume_ratio_5d") # 5일평균거래량 대비 비율 earnings_outlook = str(ctx.get("earnings_outlook") or "").upper() # IMPROVING|STABLE|DETERIORATING|UNKNOWN for name, value in ( ("short_balance_ratio", short_balance_ratio), ("short_turnover_share", short_turnover_share), ("relative_return_20d", relative_return_20d), ): if not _finite(value): missing.append(name) if missing: return { "short_interest_pressure": None, "status": "DATA_MISSING", "missing_inputs": missing, "note": "잔고율/거래비중/상대수익률 중 결측 — 공매도 합성 점수를 산출하지 않음(추정 금지)", } low_balance_regime = float(short_balance_ratio) < 1.0 # 잔고율 1% 미만이면 '낮은 잔고율' 취급(현대로템형) # 잔고율 추세: 상승=매도근거 강화, 하락=매도근거 약화(혹은 매수근거) balance_trend_signal = 0.0 if _finite(short_balance_ratio_chg_20d): balance_trend_signal = max(-1.0, min(1.0, float(short_balance_ratio_chg_20d) / 1.5)) turnover_signal = max(-1.0, min(1.0, (float(short_turnover_share) - 8.0) / 12.0)) # 8% 기준선 relative_return_signal = max(-1.0, min(1.0, -float(relative_return_20d) / 10.0)) # 상대 약세일수록 + volume_signal = 0.0 if _finite(volume_ratio_5d): volume_signal = max(-1.0, min(1.0, (float(volume_ratio_5d) - 1.0))) outlook_signal = { "IMPROVING": -0.6, "STABLE": 0.0, "DETERIORATING": 0.7, }.get(earnings_outlook, 0.0) if low_balance_regime: # 잔고율 자체는 약한 근거 — 거래비중·상대수익률 가중치 상향, 잔고율추세 가중치 하향 weights = {"balance": 0.10, "turnover": 0.30, "relative": 0.30, "volume": 0.10, "outlook": 0.20} else: weights = {"balance": 0.30, "turnover": 0.20, "relative": 0.20, "volume": 0.10, "outlook": 0.20} pressure = ( balance_trend_signal * weights["balance"] + turnover_signal * weights["turnover"] + relative_return_signal * weights["relative"] + volume_signal * weights["volume"] + outlook_signal * weights["outlook"] ) pressure = max(-1.0, min(1.0, pressure)) label = "ELEVATED_SHORT_PRESSURE" if pressure >= 0.5 else "WATCH" if pressure >= 0.2 else \ "SHORT_COVERING_SUPPORTIVE" if pressure <= -0.5 else "NEUTRAL" return { "short_interest_pressure": round(pressure, 4), "status": "OK", "low_balance_regime": low_balance_regime, "label": label, "components": { "balance_trend_signal": round(balance_trend_signal, 4), "turnover_signal": round(turnover_signal, 4), "relative_return_signal": round(relative_return_signal, 4), "volume_signal": round(volume_signal, 4), "outlook_signal": outlook_signal, }, "weights_used": weights, } def compute_microstructure_pressure_from_orderbook(orderbook_output1: dict[str, Any]) -> dict[str, Any]: """MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1. KIS Open API FHKST01010200(주식현재가 호가/예상체결) output1의 10단계 호가 잔량을 -1(매수우위/지지)~+1(매도우위/압력)로 계량화. 실측 확인된 필드명(2026-06-21, 005930 라이브 호출): total_askp_rsqn, total_bidp_rsqn(10단계 합계 잔량). 이 점수는 전략 방향 결정에는 쓰지 않고 confluence가 성립한 이후의 '집행 타이밍' 보조로만 사용한다(spec/exit/qualitative_sell_strategy_v1.yaml:factor_families. microstructure_pressure 참조). """ total_askp = orderbook_output1.get("total_askp_rsqn") total_bidp = orderbook_output1.get("total_bidp_rsqn") try: total_askp = float(total_askp) total_bidp = float(total_bidp) except (TypeError, ValueError): return {"microstructure_pressure": None, "status": "DATA_MISSING"} denom = total_askp + total_bidp if denom <= 0: return {"microstructure_pressure": None, "status": "DATA_MISSING"} pressure = max(-1.0, min(1.0, (total_askp - total_bidp) / denom)) return { "microstructure_pressure": round(pressure, 4), "status": "OK", "total_askp_rsqn": total_askp, "total_bidp_rsqn": total_bidp, } def _event_review_window( today: date, pressure_sign: int, next_earnings_date: date | None, next_macro_event_date: date | None, macro_event_impact: str | None, earnings_outlook: str, ) -> dict[str, Any]: """캘린더 기반 검토 구간 산출 — 임의 날짜 고정이 아니라 실제 이벤트 일정에서 역산.""" candidates: list[tuple[date, str]] = [] if next_earnings_date is not None: if pressure_sign < 0 and earnings_outlook == "DETERIORATING": # 실적 악화 전망 + 매도압력 → 실적발표 전 정리(서프라이즈 리스크 회피) candidates.append((next_earnings_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_EARNINGS_EXIT_BEFORE_SURPRISE_RISK")) elif pressure_sign < 0 and earnings_outlook in {"IMPROVING", "STABLE"}: # 단기 기술적 매도압력이지만 실적전망은 양호 → 발표 직전 매도는 가치훼손, 발표 이후로 연기 candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "DEFER_TO_POST_EARNINGS_AVOID_PREMATURE_EXIT")) elif pressure_sign > 0: # 추가매수/보유 신호 — 발표 변동성 통과 후 확신 재평가 candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "REASSESS_AFTER_EARNINGS_CONFIRM")) if next_macro_event_date is not None and str(macro_event_impact or "").upper() in {"HIGH", "VERY_HIGH"}: if pressure_sign < 0: candidates.append((next_macro_event_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_MACRO_EVENT_DERISK")) else: candidates.append((next_macro_event_date + timedelta(days=EVENT_POST_GUARD_DAYS), "POST_MACRO_EVENT_CONFIRM")) if not candidates: return { "review_window_start": today.isoformat(), "review_window_end": (today + timedelta(days=10)).isoformat(), "window_basis": "NO_SCHEDULED_EVENT_DEFAULT_10D_REVIEW", } earliest = min(candidates, key=lambda item: item[0]) window_start = max(today, earliest[0] - timedelta(days=2)) window_end = earliest[0] + timedelta(days=2) return { "review_window_start": window_start.isoformat(), "review_window_end": window_end.isoformat(), "window_basis": earliest[1], } def compute_qualitative_sell_strategy(ctx: dict[str, Any]) -> dict[str, Any]: """QUALITATIVE_SELL_STRATEGY_V1. 매크로/실적/펀더멘털/공매도수급/호가미시구조/대내외(IPO·로테이션) 5개 독립 팩터군의 합의(confluence)로만 행동을 생성한다. 현금부족 사유는 입력에서 의도적으로 배제(cash_shortfall_excluded=True) — 가치보존이 유일한 목적 함수. """ today_raw = ctx.get("today") today = today_raw if isinstance(today_raw, date) else date.today() factor_values: dict[str, float | None] = {} missing_factors: list[str] = [] for family in FACTOR_FAMILIES: value = ctx.get(family) if _finite(value): factor_values[family] = max(-1.0, min(1.0, float(value))) else: factor_values[family] = None missing_factors.append(family) available = {k: v for k, v in factor_values.items() if v is not None} if len(available) < CONFLUENCE_MIN: return { "action": "INSUFFICIENT_DATA_NO_ACTION", "conviction": "NONE", "available_factors": list(available.keys()), "missing_factors": missing_factors, "rationale": "5개 팩터군 중 confluence 판정에 필요한 최소 데이터가 부족 — 추정으로 행동 생성 금지", "cash_shortfall_excluded": True, "mechanical_sell_prohibited": True, } # 부호 규약: 모든 팩터군은 +1(매도압력 최대) ~ -1(보유/추가 지지 최대) 동일 스케일. # short_interest_pressure도 동일 — ELEVATED_SHORT_PRESSURE(+) / SHORT_COVERING_SUPPORTIVE(-). # confluence 합의 카운트는 국면 가중치와 무관하게 원시 방향성으로만 판정한다 # (가중치는 행동 '강도'에만 영향 — 합의 성립 여부 자체를 왜곡하지 않는다). sell_agree = [k for k, v in available.items() if v >= 0.30] hold_add_agree = [k for k, v in available.items() if v <= -0.30] market_regime = classify_market_regime(ctx.get("rate_trend")) if "market_regime" not in ctx else str(ctx.get("market_regime") or "NEUTRAL").upper() regime_weights = REGIME_WEIGHT_TABLE.get(market_regime, REGIME_FLAT_WEIGHTS) weighted_sum = sum(available[k] * regime_weights.get(k, 1.0) for k in available) weight_total = sum(regime_weights.get(k, 1.0) for k in available) composite_score = weighted_sum / weight_total if weight_total else 0.0 earnings_outlook = str(ctx.get("earnings_outlook") or "STABLE").upper() next_earnings_date = ctx.get("next_earnings_date") if isinstance(ctx.get("next_earnings_date"), date) else None next_macro_event_date = ctx.get("next_macro_event_date") if isinstance(ctx.get("next_macro_event_date"), date) else None macro_event_impact = ctx.get("macro_event_impact") if len(sell_agree) >= CONFLUENCE_MIN: conviction = "HIGH" if len(sell_agree) >= 4 else "MEDIUM" action = "EXIT_REVIEW_FULL" if composite_score >= 0.6 else "TRIM_REVIEW_PARTIAL" pressure_sign = -1 rationale = f"매도압력 합의({len(sell_agree)}/{len(available)} 팩터군 매도방향 합치): " + ", ".join(sell_agree) elif len(hold_add_agree) >= CONFLUENCE_MIN: conviction = "HIGH" if len(hold_add_agree) >= 4 else "MEDIUM" action = "HOLD_ADD_CONVICTION" pressure_sign = 1 rationale = f"보유/추가 근거 합의({len(hold_add_agree)}/{len(available)} 팩터군 지지방향 합치): " + ", ".join(hold_add_agree) else: conviction = "LOW" action = "HOLD_NO_CONFLUENCE" pressure_sign = 0 rationale = "팩터군 간 합의 미달 — 단일/소수 팩터의 임계값 돌파만으로는 매도 트리거 금지" window = _event_review_window( today=today, pressure_sign=pressure_sign, next_earnings_date=next_earnings_date, next_macro_event_date=next_macro_event_date, macro_event_impact=macro_event_impact, earnings_outlook=earnings_outlook, ) if pressure_sign != 0 else None return { "action": action, "conviction": conviction, "market_regime": market_regime, "composite_score": round(composite_score, 4), "sell_agreeing_factors": sell_agree, "hold_add_agreeing_factors": hold_add_agree, "missing_factors": missing_factors, "review_window": window, "rationale": rationale, "cash_shortfall_excluded": True, "mechanical_sell_prohibited": True, } def compute_satellite_candidate_score(ctx: dict[str, Any]) -> dict[str, Any]: """SATELLITE_CANDIDATE_SCORE_V1. 미보유 유니버스 종목을 섹터 수출입 전망(sector_export_trend) + 펀더멘털 추세 + 국면적합도로 평가해 WATCH/BUY_CANDIDATE/AVOID를 산출한다. 보유종목 매도판단(compute_qualitative_sell_strategy)과 동일한 부호 규약을 쓰지 않고 별도 -1(약세)~+1(강세) 매력도 스케일을 쓴다 — 매수후보 평가와 매도판단은 목적함수가 다르므로 동일 점수를 재사용하지 않는다. """ sector_export_trend = ctx.get("sector_export_trend") # %, 섹터 수출 YoY/MoM 추세 fundamental_trajectory = ctx.get("fundamental_trajectory") # -1(악화)~+1(개선), 매도엔진과 동일 정의역이나 부호 반대 해석 주의 relative_return_20d = ctx.get("relative_return_20d") market_regime = str(ctx.get("market_regime") or classify_market_regime(ctx.get("rate_trend"))).upper() missing = [name for name, value in ( ("sector_export_trend", sector_export_trend), ("fundamental_trajectory", fundamental_trajectory), ) if not _finite(value)] if missing: return { "satellite_action": "INSUFFICIENT_DATA_NO_ACTION", "missing_inputs": missing, "market_regime": market_regime, } export_signal = max(-1.0, min(1.0, float(sector_export_trend) / 10.0)) fundamental_signal = max(-1.0, min(1.0, -float(fundamental_trajectory))) # 매도엔진 부호(+)=악화 -> 매력도는 반전 relative_signal = max(-1.0, min(1.0, float(relative_return_20d) / 10.0)) if _finite(relative_return_20d) else 0.0 if market_regime == "PERFORMANCE_MARKET": weights = {"export": 0.45, "fundamental": 0.40, "relative": 0.15} elif market_regime == "TECHNICAL_MARKET": weights = {"export": 0.20, "fundamental": 0.25, "relative": 0.55} else: weights = {"export": 0.34, "fundamental": 0.33, "relative": 0.33} attractiveness = ( export_signal * weights["export"] + fundamental_signal * weights["fundamental"] + relative_signal * weights["relative"] ) attractiveness = max(-1.0, min(1.0, attractiveness)) if attractiveness >= 0.5: satellite_action = "BUY_CANDIDATE" elif attractiveness >= 0.2: satellite_action = "WATCH" elif attractiveness <= -0.4: satellite_action = "AVOID" else: satellite_action = "NEUTRAL_NO_EDGE" return { "satellite_action": satellite_action, "attractiveness_score": round(attractiveness, 4), "market_regime": market_regime, "components": { "export_signal": round(export_signal, 4), "fundamental_signal": round(fundamental_signal, 4), "relative_signal": round(relative_signal, 4), }, "weights_used": weights, }