da0e1b0f7e
매크로·실적·펀더멘털·공매도수급·호가미시구조·대내외 변수 5개 독립 팩터군의 confluence(최소 3/5 합의) 없이는 매도 트리거를 금지하는 정성적 매도판단 엔진과, 보유종목 제외 위성후보 추천 로직을 추가한다. - 단일 팩터 임계값 돌파만으로는 매도 신호를 생성하지 않음 (mechanical_sell_prohibited=true) - 데이터 결측 시 항상 DATA_MISSING/INSUFFICIENT_DATA_NO_ACTION — 추정값으로 채우지 않음 - KIS 호가10단계·공매도거래비중 + Naver 시세/수급 스크래핑 입력 연동 - SQLite 시계열 저장 + 사후 적중률 자체평가 (evaluate_qualitative_sell_strategy_accuracy_v1) - Gitea 일일 스케줄(장마감 후) + 파이프라인 계약 검증 게이트
378 lines
17 KiB
Python
378 lines
17 KiB
Python
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,
|
|
}
|