"""Exit Decisions Parity Module v2.0 (30-Principle Refactored) Strategic Principles Applied: 1. SOLID: Strategy pattern for decision logic separation 2. Refactoring: Functions <50 lines each 3. Consistency: Type-safe, contract-enforced 4. Parsimony: Magic numbers → named constants 11. Vibes Coding: Clear naming, minimal cognitive load 12. Hallucination Prevention: All constants sourced from KIS rules 14. Traceability: Reason field for all decisions 19. Type Safety: TypedDict for inputs/outputs 20. Accessibility: Validation, clear error messages 23. Security: Decimal for financial calculations 28. Documentation: Docstrings for all functions """ from __future__ import annotations from typing import TypedDict, Optional from dataclasses import dataclass import math from decimal import Decimal # ===== CONSTANTS (Principle 4: Parsimony, Principle 12: Sourced) ===== class PriceTickRules: """한국거래소(KIS) 기준 가격 호가 규칙""" TIER_1_THRESHOLD = 2000 TIER_1_TICK = 1 TIER_2_THRESHOLD = 5000 TIER_2_TICK = 5 TIER_3_THRESHOLD = 20000 TIER_3_TICK = 10 TIER_4_THRESHOLD = 50000 TIER_4_TICK = 50 TIER_5_THRESHOLD = 200000 TIER_5_TICK = 100 TIER_6_THRESHOLD = 500000 TIER_6_TICK = 500 TIER_7_TICK = 1000 class ProfitThresholds: """이익 실현 임계값""" TP2_PCT = 50.0 TP1_VALIDATION_PCT = 20.0 TP1_TRIGGER_PCT = 10.0 class TimeExitThresholds: """시간 기반 청산 임계값 (영업일 기준)""" EXIT_FULL = 0 TRIM_APPROACHING = (6, 7) TRIM_2WK_GATE = 14 HOLD_THRESHOLD = 15 class ProtectionFactors: """보호 계수""" CLOSE_PROTECTION = Decimal("0.998") # ===== INPUT/OUTPUT TYPES (Principle 19: Type Safety) ===== class SellDecisionInput(TypedDict, total=False): """매도 결정 입력 데이터""" close: float profitPct: float tp1Price: Optional[float] tp2Price: Optional[float] rwPartial: Optional[int] daysToTimeStop: Optional[int] @dataclass class SellDecision: """매도 결정 결과 (Principle 14: Traceability)""" action: str ratio_pct: int price_basis: str order_type: str limit_price: float reason: str validation: str = "" price_source: str = "" def to_dict(self) -> dict: result = { "action": self.action, "ratio_pct": self.ratio_pct, "price_basis": self.price_basis, "order_type": self.order_type, "limit_price": self.limit_price, "reason": self.reason, } if self.validation: result["validation"] = self.validation if self.price_source: result["price_source"] = self.price_source return result @dataclass class StopAction: """정지 조치 결과""" action: str quantity_pct: int priority: float reason: str def to_dict(self) -> dict: return { "action": self.action, "quantity_pct": self.quantity_pct, "priority": self.priority, "reason": self.reason, } # ===== CORE FUNCTIONS (Principle 1: SOLID - Single Responsibility) ===== def normalize_tick(price: float) -> float: """가격을 KIS 호가 단위로 정규화 (Principle 3: Consistency) Args: price: 정규화할 가격 Returns: KIS 기준으로 정규화된 가격 """ if price < PriceTickRules.TIER_1_THRESHOLD: return math.floor(price) elif price < PriceTickRules.TIER_2_THRESHOLD: return math.floor(price / PriceTickRules.TIER_2_TICK) * PriceTickRules.TIER_2_TICK elif price < PriceTickRules.TIER_3_THRESHOLD: return math.floor(price / PriceTickRules.TIER_3_TICK) * PriceTickRules.TIER_3_TICK elif price < PriceTickRules.TIER_4_THRESHOLD: return math.floor(price / PriceTickRules.TIER_4_TICK) * PriceTickRules.TIER_4_TICK elif price < PriceTickRules.TIER_5_THRESHOLD: return math.floor(price / PriceTickRules.TIER_5_TICK) * PriceTickRules.TIER_5_TICK elif price < PriceTickRules.TIER_6_THRESHOLD: return math.floor(price / PriceTickRules.TIER_6_TICK) * PriceTickRules.TIER_6_TICK else: return math.floor(price / PriceTickRules.TIER_7_TICK) * PriceTickRules.TIER_7_TICK # ===== STRATEGY FUNCTIONS (Principle 1: SOLID - Strategy Pattern) ===== def _check_time_exit(item: SellDecisionInput) -> Optional[SellDecision]: """시간 기반 청산 전략""" days = item.get("daysToTimeStop") if days is None: return None close = item.get("close", 0) if days == TimeExitThresholds.EXIT_FULL: return SellDecision( action="TIME_EXIT_100", ratio_pct=100, price_basis="TIME_STOP_CLOSE_PROTECT", order_type="LIMIT_SELL", limit_price=close, reason="TIME_STOP_EXPIRED", ) elif days in TimeExitThresholds.TRIM_APPROACHING: return SellDecision( action="TIME_TRIM_50", ratio_pct=50, price_basis="TIME_STOP_CLOSE_PROTECT", order_type="LIMIT_SELL", limit_price=close, reason="TIME_STOP_APPROACHING", ) elif days == TimeExitThresholds.TRIM_2WK_GATE: return SellDecision( action="TIME_TRIM_25", ratio_pct=25, price_basis="TIME_STOP_CLOSE_PROTECT", order_type="LIMIT_SELL", limit_price=close, reason="TIME_STOP_2WK_GATE", ) elif days >= TimeExitThresholds.HOLD_THRESHOLD: return SellDecision( action="HOLD", ratio_pct=0, price_basis="MARKET_CLOSE", order_type="NONE", limit_price=close, reason="TIME_STOP_NOT_ACTIVE", ) return None def _check_relative_weakness(item: SellDecisionInput) -> Optional[SellDecision]: """상대약세(RW) 기반 전략""" rw_partial = item.get("rwPartial") if rw_partial is None: return None close = item.get("close", 0) limit_price = float(Decimal(str(close)) * ProtectionFactors.CLOSE_PROTECTION) if rw_partial == 1: return SellDecision( action="TRIM_25", ratio_pct=25, price_basis="PRIOR_CLOSE_X_0.998", order_type="LIMIT_SELL", limit_price=limit_price, reason="RW_PARTIAL_1", ) elif rw_partial == 2: return SellDecision( action="TRIM_50", ratio_pct=50, price_basis="PRIOR_CLOSE_X_0.998", order_type="LIMIT_SELL", limit_price=limit_price, reason="RW_PARTIAL_2", ) return None def _check_profit_taking(item: SellDecisionInput) -> Optional[SellDecision]: """이익 실현 전략 (TP2, TP1)""" close = item.get("close", 0) profit_pct = item.get("profitPct", 0.0) or 0.0 tp1_price = item.get("tp1Price") tp2_price = item.get("tp2Price") limit_price_protect = float(Decimal(str(close)) * ProtectionFactors.CLOSE_PROTECTION) if profit_pct >= ProfitThresholds.TP2_PCT: if tp2_price is not None and tp2_price > 0: return SellDecision( action="TAKE_PROFIT_TIER2", ratio_pct=50, price_basis="TAKE_PROFIT_TIER2_PRICE", order_type="LIMIT_SELL", limit_price=float(tp2_price), reason="TP2_PROFIT_50PCT", ) else: return SellDecision( action="PROFIT_TRIM_50", ratio_pct=50, price_basis="PRIOR_CLOSE_X_0.998", order_type="LIMIT_SELL", limit_price=limit_price_protect, reason="TP2_PROFIT_50PCT_NO_TARGET", price_source="CLOSE_PROFIT_PROTECT", ) if profit_pct >= ProfitThresholds.TP1_VALIDATION_PCT and tp1_price is None: return SellDecision( action="PROFIT_TRIM_25", ratio_pct=25, price_basis="PRIOR_CLOSE_X_0.998", order_type="LIMIT_SELL", limit_price=limit_price_protect, reason="TP1_PROFIT_20PCT_NO_TARGET", validation="SIGNAL_CONFIRMED", ) if profit_pct >= ProfitThresholds.TP1_TRIGGER_PCT: if tp1_price is not None and tp1_price > 0: return SellDecision( action="TAKE_PROFIT_TIER1", ratio_pct=25, price_basis="TAKE_PROFIT_TIER1_PRICE", order_type="LIMIT_SELL", limit_price=float(tp1_price), reason="TP1_PROFIT_10PCT", ) else: return SellDecision( action="TAKE_PROFIT_TIER1", ratio_pct=25, price_basis="PRIOR_CLOSE_X_0.998", order_type="LIMIT_SELL", limit_price=limit_price_protect, reason="TP1_PROFIT_10PCT_NO_TARGET", ) return None # ===== PUBLIC API FUNCTIONS ===== def compute_sell_decision(item: dict) -> dict: """매도 결정 통합 함수 (Principle 1: SOLID via delegation) 우선순위: 1. 시간 청산 (daysToTimeStop) 2. 상대약세 (rwPartial) 3. 이익 실현 (profitPct, TP targets) 4. 보유 (HOLD) """ decision = _check_time_exit(item) if decision: return decision.to_dict() decision = _check_relative_weakness(item) if decision: return decision.to_dict() decision = _check_profit_taking(item) if decision: return decision.to_dict() close = item.get("close", 0) return SellDecision( action="HOLD", ratio_pct=0, price_basis="MARKET_CLOSE", order_type="NONE", limit_price=close, reason="NO_EXIT_SIGNAL", ).to_dict() def compute_stop_action_ladder(item: dict) -> dict: """정지 조치 우선순위 사다리 (Principle 1: SOLID) 우선순위: 1. timing_action = STOP_OR_TIME_EXIT_READY → EXIT_100 2. regime = RISK_OFF → REGIME_TRIM_50 3. RW + rapid weakness → TRIM_50 4. Trailing stop breach → TRIM_50 5. profit_pct >= 10% → TAKE_PROFIT_TIER1 6. 수동 검토 필요 → REVIEW_HUMAN 7. HOLD (기본값) """ profit_pct = item.get("profitPct", 0.0) or 0.0 days_to_time_stop = item.get("daysToTimeStop") timing_action = item.get("timingAction") regime = item.get("REGIME_PRELIM") rw_partial_ex = item.get("rw_partial_excluding_rw2b") rw2b = item.get("RW2b_5d_rapid_weakness") trailing = item.get("trailingStopBreach") if timing_action == "STOP_OR_TIME_EXIT_READY": return StopAction( action="EXIT_100", quantity_pct=100, priority=1, reason="STOP_OR_TIME_EXIT_READY", ).to_dict() if regime == "RISK_OFF": return StopAction( action="REGIME_TRIM_50", quantity_pct=50, priority=2, reason="REGIME_RISK_OFF", ).to_dict() if rw_partial_ex == 1 and rw2b: return StopAction( action="TRIM_50", quantity_pct=50, priority=2.5, reason="RW_AND_RAPID_WEAKNESS", ).to_dict() if trailing: return StopAction( action="TRIM_50", quantity_pct=50, priority=4, reason="TRAILING_STOP_BREACH", ).to_dict() if profit_pct >= 10.0: return StopAction( action="TAKE_PROFIT_TIER1", quantity_pct=25, priority=5, reason="PROFIT_PCT_THRESHOLD", ).to_dict() if profit_pct < 10.0 and days_to_time_stop == 1: return StopAction( action="REVIEW_HUMAN", quantity_pct=0, priority=6, reason="MANUAL_REVIEW_REQUIRED", ).to_dict() return StopAction( action="HOLD", quantity_pct=0, priority=99, reason="NO_ACTION_TRIGGERED", ).to_dict() def compute_timing_decision(item: dict) -> dict: """타이밍 결정 (진입/청산 신호) 데이터 필수 조건: atr20 필드 필수 (변동성 기반) """ if item.get("atr20") is None: return {"action": "OBSERVE_DATA_MISSING", "entry_score": 0, "exit_score": 0} mode = item.get("entryMode", "") ac_gate = item.get("acGate", "") rw_partial = item.get("rwPartial", 0) or 0 days_to_time_stop = item.get("daysToTimeStop") if ac_gate == "BLOCK" and days_to_time_stop is not None and days_to_time_stop <= 5: return {"action": "STOP_OR_TIME_EXIT_READY", "entry_score": 50, "exit_score": 85} if rw_partial == 2 or (item.get("ma20Slope", 0) < 0 and item.get("disparity", 0) > 8): return {"action": "EXIT_REVIEW", "entry_score": 40, "exit_score": 60} if mode == "BREAKOUT" and ac_gate == "CLEAR": return {"action": "BUY_BREAKOUT_PILOT_ONLY", "entry_score": 80, "exit_score": 10} if mode == "PULLBACK": return {"action": "BUY_PULLBACK_WAIT", "entry_score": 65, "exit_score": 20} return {"action": "OBSERVE", "entry_score": 50, "exit_score": 20} def compute_final_decision(item: dict) -> dict: """최종 의사결정 라우팅 (Principle 1: SOLID) 우선순위: 1. sell_action != HOLD → 매도 신호 우선 2. timing_action 신호 → 타이밍 제어 3. dartRisk → DART 위험 회피 4. allowed_action → 허용된 진입 5. HOLD (기본값) """ sell_action = item.get("sellAction", "HOLD") allowed_action = item.get("allowedAction", "") timing_action = item.get("timingAction", "") dart_risk = item.get("dartRisk", False) if sell_action != "HOLD": return {"final_action": sell_action, "action_priority": 1} if timing_action in ("STOP_OR_TIME_EXIT_READY", "NO_BUY_OVERHEATED"): priority = 50 if timing_action == "NO_BUY_OVERHEATED" else 10 return {"final_action": timing_action, "action_priority": priority} if dart_risk: return {"final_action": "EXIT_DART_RISK", "action_priority": 20} if allowed_action: return {"final_action": allowed_action, "action_priority": 30} return {"final_action": "HOLD", "action_priority": 99}