""" Exit/sell action decision logic for portfolio execution. F05/F10 porting: Determines the sell action, ratio, price target, and execution details based on market signals (RW, timing, profit levels, time stops, stop losses). Ported from: src/gas_adapter_parts/gdf_01_price_metrics.gs:calcExitSellAction_ src/gas_adapter_parts/gdf_01_price_metrics.gs:calcCashPreservationPlan_ Parity reference: tests/parity/test_execution_decision_parity_v1.py """ import math import re from typing import Any, Optional def is_finite(value: Any) -> bool: """Check if value is a finite number (matches JavaScript Number.isFinite()).""" return isinstance(value, (int, float)) and math.isfinite(value) def calc_cash_preservation_plan(ctx: dict[str, Any]) -> dict[str, Any]: """ Calculate cash preservation adjustment to sell action. Factors: core/leader status, rebound holdback score, cash floor, regime, liquidity, account type (tax), RW signals. Args: ctx: Dict with keys: - cashFloorStatus: "TRIM_REQUIRED", "HARD_BLOCK", etc. - regime: Market regime (e.g., "RISK_OFF") - sellAction: Sell action (e.g., "TRIM_50") - isCoreLeader: bool - isEtf: bool - liquidityStatus: "LOW", "OK", etc. - spreadStatus: "WIDE", "OK", "BLOCK", etc. - accountType: "일반계좌", "연금계좌", etc. - profitPct: Profit percentage - rwPartial: Relative weakness signal count (0-5) - reboundHoldbackScore: Rebound preservation score Returns: Dict: { "style": "CORE_LAST" | "STEP_25" | "STEP_33" | "STEP_50", "recommended_ratio": 0-50 (sell ratio override), "protection_bonus": integer (risk bonus points), "reasons": "reason1 | reason2 | ..." } """ cash_floor_status = str(ctx.get("cashFloorStatus", "")) regime = str(ctx.get("regime", "")) sell_action = str(ctx.get("sellAction", ctx.get("action", ""))) is_sell_like = re.search(r"(SELL|TRIM|EXIT)", sell_action) is not None is_core_leader = bool(ctx.get("isCoreLeader")) is_etf = bool(ctx.get("isEtf")) liquidity_status = str(ctx.get("liquidityStatus", "")) spread_status = str(ctx.get("spreadStatus", "")) account_type = str(ctx.get("accountType", "")) profit_pct = float(ctx.get("profitPct", float("nan"))) rw_partial = int(ctx.get("rwPartial", 0)) rebound_holdback = float(ctx.get("reboundHoldbackScore", float("nan"))) holdback_score = rebound_holdback if is_finite(rebound_holdback) else 0 recommended_ratio = 50 if is_sell_like else 0 style = "STEP_50" protection_bonus = 0 reasons = [] if is_core_leader and holdback_score >= 12: style = "CORE_LAST" recommended_ratio = 25 if cash_floor_status == "TRIM_REQUIRED" else 0 protection_bonus += 12 reasons.append("core_last") elif holdback_score >= 18: style = "STEP_25" recommended_ratio = 25 protection_bonus += 10 reasons.append("strong_rebound") elif holdback_score >= 10: style = "STEP_33" recommended_ratio = 33 protection_bonus += 6 reasons.append("rebound_preserve") if is_etf and holdback_score < 10: protection_bonus -= 2 reasons.append("etf_cash_raise") if cash_floor_status == "TRIM_REQUIRED" or re.search(r"RISK_OFF", regime): protection_bonus += 2 reasons.append("cash_preserve") if liquidity_status == "LOW" or spread_status in ("WIDE", "BLOCK"): protection_bonus += 4 reasons.append("impact_avoid") if account_type == "일반계좌" and is_finite(profit_pct) and profit_pct > 0: protection_bonus += 3 if profit_pct >= 20 else 2 reasons.append("tax_drag") elif account_type == "일반계좌" and is_finite(profit_pct) and profit_pct < 0: protection_bonus -= 2 reasons.append("tax_loss_harvest") if rw_partial >= 3 and not is_core_leader: recommended_ratio = max(recommended_ratio, 50) protection_bonus -= 4 reasons.append("rw_force") if cash_floor_status == "HARD_BLOCK": recommended_ratio = max(recommended_ratio, 50) reasons.append("cash_hard_block") if not is_sell_like: recommended_ratio = 0 recommended_ratio = max(0, min(50, recommended_ratio)) return { "style": style, "recommended_ratio": recommended_ratio, "protection_bonus": max(0, round(protection_bonus)), "reasons": " | ".join(reasons), } def calc_exit_sell_action(ctx: dict[str, Any]) -> dict[str, Any]: """ Determine exit/sell action based on priority matrix of signals. Priority hierarchy (spec/exit/stop_loss.yaml): 1. Hard stop / strong RW (EXIT_100, rwPartial >= 4) 2. REGIME_TRIM_50 (RISK_OFF — portfolio-level, skipped here) 3. RW strong + timing (TRIM_70) 4. Trailing stop breach 5. RW medium / timing-based trims (TRIM_50, TRIM_33, TRIM_25) 6. Profit-taking ladder (TP1/TP2 tiers) 7. Time stop (TIME_EXIT_100, TIME_TRIM_*) Args: ctx: Dict with keys from data_feed row + macro context: - close, stopPrice, trailingStop, tp1Price, tp2Price, profitPct - rwPartial, timingExitScore, daysToTimeStop, timingAction - exitSignalDetail, acGate, regime, atr20 - cashFloorStatus, isCoreLeader, isEtf, liquidityStatus, spreadStatus - accountType, reboundHoldbackScore Returns: Dict: { "action": "HOLD" | "EXIT_100" | "TRIM_70" | ... | "TIME_TRIM_25", "ratio_pct": 0-100, "limit_price": price (KRW integer) or "", "price_source": "TP2_PRICE" | "TRAILING_STOP" | ... | "ATR_PROTECT_LIMIT", "price_basis": "TAKE_PROFIT_TIER2_PRICE" | ... | "ATR_PROTECT_LIMIT", "execution_window": "INTRADAY_ON_TRIGGER" | "INTRADAY_LIMIT_OR_CLOSE_REVIEW" | ..., "order_type": "LIMIT_SELL" | "PROTECTIVE_LIMIT_SELL", "reason": "RW_EXIT_STRONG" | ... | "TIME_STOP_APPROACHING", "validation": "SIGNAL_CONFIRMED" | "NO_SELL_PRICE" | "NO_SELL_ACTION", "cash_preserve_style": "STEP_50" | ..., "cash_preserve_ratio": 0-50, "cash_preserve_reason": "..." } """ def safe_float(v, default=float("nan")): """Safely convert to float, handling None/invalid values.""" if v is None or v == "": return default try: return float(v) except (ValueError, TypeError): return default close = safe_float(ctx.get("close")) stop_price = safe_float(ctx.get("stopPrice")) trailing_stop = safe_float(ctx.get("trailingStop")) tp1_price = safe_float(ctx.get("tp1Price")) tp2_price = safe_float(ctx.get("tp2Price")) profit_pct = safe_float(ctx.get("profitPct")) rw_partial = int(ctx.get("rwPartial", 0)) timing_exit_score = safe_float(ctx.get("timingExitScore")) days_to_time_stop = int(ctx.get("daysToTimeStop", 999)) timing_action = str(ctx.get("timingAction", "")) regime = str(ctx.get("regime", "")) atr20 = safe_float(ctx.get("atr20")) action = "HOLD" ratio = 0 reason = "" price = "" price_source = "" price_basis = "" execution_window = "" order_type = "" # Calculate protective limits stop_candidate = ( trailing_stop if is_finite(trailing_stop) and trailing_stop > 0 else stop_price if is_finite(stop_price) and stop_price > 0 else close * 0.995 if is_finite(close) and close > 0 else None ) protective_limit = ( round(min(close * 0.995, stop_candidate if stop_candidate else close * 0.995)) if is_finite(close) and close > 0 else "" ) atr_buffer = ( atr20 * 0.3 if is_finite(atr20) and atr20 > 0 else close * 0.005 if is_finite(close) else 0 ) close_protect_limit = ( round(close - atr_buffer) if is_finite(close) and close > 0 else "" ) # Priority 1: Hard stop / strong RW if timing_action == "STOP_OR_TIME_EXIT_READY" or rw_partial >= 4: action = "EXIT_100" ratio = 100 reason = "RW_EXIT_STRONG" if rw_partial >= 4 else "STOP_OR_TIME_EXIT_READY" price = protective_limit price_source = "TRAILING_STOP" if is_finite(trailing_stop) else "STOP_OR_CLOSE" price_basis = "TRAILING_STOP_TRIGGER" if is_finite(trailing_stop) else "STOP_OR_CLOSE_PROTECT" execution_window = "INTRADAY_ON_TRIGGER" order_type = "PROTECTIVE_LIMIT_SELL" # Priority 3: RW strong + timing elif rw_partial >= 3 or timing_exit_score >= 75: action = "TRIM_70" ratio = 70 reason = "RW_EXIT" if rw_partial >= 3 else "TIMING_EXIT_SCORE" price = protective_limit price_source = "RISK_REDUCTION" price_basis = "RISK_REDUCTION_CLOSE_PROTECT" execution_window = "INTRADAY_AFTER_09_30" order_type = "PROTECTIVE_LIMIT_SELL" # Priority 4: Trailing stop breach elif is_finite(trailing_stop) and trailing_stop > 0 and is_finite(close) and close <= trailing_stop: action = "TRAILING_STOP_BREACH" ratio = 70 reason = "TRAILING_STOP_PRICE_BREACH" price = round(trailing_stop) price_source = "TRAILING_STOP_PRICE" price_basis = "TRAILING_STOP_TRIGGER" execution_window = "INTRADAY_ON_TRIGGER" order_type = "PROTECTIVE_LIMIT_SELL" # Priority 4 (cont): RW medium elif rw_partial >= 2 or (rw_partial >= 1 and timing_exit_score >= 50): action = "TRIM_50" ratio = 50 reason = "RW_REVIEW" if rw_partial >= 2 else "TIMING_EXIT_REVIEW" price = close_protect_limit price_source = "RELATIVE_WEAKNESS_CLOSE" price_basis = "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_AFTER_09_30" order_type = "LIMIT_SELL" # Priority 4b: RW early warning elif rw_partial >= 1 and timing_exit_score >= 30: action = "TRIM_33" ratio = 33 reason = "RW_EARLY_WARNING" price = close_protect_limit price_source = "EARLY_WARNING_CLOSE" price_basis = "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_AFTER_09_30" order_type = "LIMIT_SELL" # Priority 4c: RW signal only elif rw_partial >= 1: action = "TRIM_25" ratio = 25 reason = "RW_SIGNAL_ONLY" price = close_protect_limit price_source = "SIGNAL_ONLY_CLOSE" price_basis = "PRIOR_CLOSE_X_0.998" execution_window = "CLOSE_REVIEW_OR_NEXT_OPEN" order_type = "LIMIT_SELL" # Priority 5: Profit-taking ladder elif is_finite(profit_pct) and profit_pct >= 50: action = "PROFIT_TRIM_50" ratio = 50 reason = "PROFIT_PROTECT_50" price = round(tp2_price) if is_finite(tp2_price) and tp2_price > 0 else close_protect_limit price_source = "TP2_PRICE" if is_finite(tp2_price) else "CLOSE_PROFIT_PROTECT" price_basis = "TAKE_PROFIT_TIER2_PRICE" if is_finite(tp2_price) else "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW" order_type = "LIMIT_SELL" elif is_finite(profit_pct) and profit_pct >= 30: action = "PROFIT_TRIM_35" ratio = 35 reason = "PROFIT_PROTECT_30" price = round(tp2_price) if is_finite(tp2_price) and tp2_price > 0 else close_protect_limit price_source = "TP2_PRICE" if is_finite(tp2_price) else "CLOSE_PROFIT_PROTECT" price_basis = "TAKE_PROFIT_TIER2_PRICE" if is_finite(tp2_price) else "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW" order_type = "LIMIT_SELL" elif is_finite(profit_pct) and profit_pct >= 20: action = "PROFIT_TRIM_25" ratio = 25 reason = "PROFIT_PROTECT_20" price = round(tp1_price) if is_finite(tp1_price) and tp1_price > 0 else close_protect_limit price_source = "TP1_PRICE" if is_finite(tp1_price) else "CLOSE_PROFIT_PROTECT" price_basis = "TAKE_PROFIT_TIER1_PRICE" if is_finite(tp1_price) else "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW" order_type = "LIMIT_SELL" elif is_finite(profit_pct) and profit_pct >= 10: action = "TAKE_PROFIT_TIER1" ratio = 25 reason = "TP1_PROFIT_10PCT" price = round(tp1_price) if is_finite(tp1_price) and tp1_price > 0 else close_protect_limit price_source = "TP1_PRICE" if is_finite(tp1_price) else "CLOSE_PROFIT_PROTECT" price_basis = "TAKE_PROFIT_TIER1_PRICE" if is_finite(tp1_price) else "PRIOR_CLOSE_X_0.998" execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW" order_type = "LIMIT_SELL" # Priority 6: Time stop elif is_finite(days_to_time_stop) and days_to_time_stop <= 0: action = "TIME_EXIT_100" ratio = 100 reason = "TIME_STOP_EXPIRED" price = protective_limit price_source = "TIME_STOP_CLOSE" price_basis = "TIME_STOP_CLOSE_PROTECT" execution_window = "CLOSE_REVIEW_OR_NEXT_OPEN" order_type = "PROTECTIVE_LIMIT_SELL" elif is_finite(days_to_time_stop) and days_to_time_stop <= 7: action = "TIME_TRIM_50" ratio = 50 reason = "TIME_STOP_NEAR" price = close_protect_limit price_source = "TIME_STOP_NEAR_CLOSE" price_basis = "ATR_PROTECT_LIMIT" execution_window = "CLOSE_REVIEW_OR_NEXT_OPEN" order_type = "LIMIT_SELL" elif is_finite(days_to_time_stop) and days_to_time_stop <= 14: action = "TIME_TRIM_25" ratio = 25 reason = "TIME_STOP_APPROACHING" price = close_protect_limit price_source = "TIME_STOP_APPROACHING_CLOSE" price_basis = "ATR_PROTECT_LIMIT" execution_window = "CLOSE_REVIEW_OR_NEXT_OPEN" order_type = "LIMIT_SELL" # Apply cash preservation plan adjustments cash_preserve_plan = calc_cash_preservation_plan({ "cashFloorStatus": ctx.get("cashFloorStatus", ""), "regime": regime, "sellAction": action, "isCoreLeader": ctx.get("isCoreLeader"), "isEtf": ctx.get("isEtf"), "liquidityStatus": ctx.get("liquidityStatus", ""), "spreadStatus": ctx.get("spreadStatus", ""), "accountType": ctx.get("accountType", ""), "profitPct": profit_pct, "rwPartial": rw_partial, "reboundHoldbackScore": float(ctx.get("reboundHoldbackScore", float("nan"))), }) if action not in ("EXIT_100", "TRAILING_STOP_BREACH", "HOLD"): target_ratio = cash_preserve_plan.get("recommended_ratio", 0) if is_finite(target_ratio) and target_ratio > 0 and target_ratio < ratio: ratio = target_ratio if ratio <= 25: action = "TRIM_25" elif ratio <= 33: action = "TRIM_33" else: action = "TRIM_50" reason = ( f"{reason}|CASH_PRESERVE:{cash_preserve_plan['style']}" if reason else f"CASH_PRESERVE:{cash_preserve_plan['style']}" ) # SL003 Priority Matrix: when multiple stop conditions trigger, use max price is_stop_type_action = re.match( r"^(EXIT_100|TRIM_70|TRAILING_STOP_BREACH|TRIM_50|TRIM_33|TRIM_25|TIME_EXIT_100|TIME_TRIM_50|TIME_TRIM_25)$", action ) is not None if is_stop_type_action and is_finite(close) and close > 0: slp_candidates = [] if timing_action == "STOP_OR_TIME_EXIT_READY" or rw_partial >= 4: if is_finite(protective_limit) and protective_limit > 0: slp_candidates.append({"src": "HARD_STOP", "p": protective_limit}) if rw_partial >= 3 or timing_exit_score >= 75: if is_finite(protective_limit) and protective_limit > 0: slp_candidates.append({"src": "RW_TRIM70", "p": protective_limit}) if is_finite(trailing_stop) and trailing_stop > 0 and is_finite(close) and close <= trailing_stop: slp_candidates.append({"src": "TRAILING", "p": round(trailing_stop)}) if rw_partial >= 2 or (rw_partial >= 1 and timing_exit_score >= 50): if is_finite(close_protect_limit) and close_protect_limit > 0: slp_candidates.append({"src": "RW_TRIM50", "p": close_protect_limit}) if is_finite(days_to_time_stop) and days_to_time_stop <= 7: if is_finite(close_protect_limit) and close_protect_limit > 0: slp_candidates.append({"src": "TIME_STOP", "p": close_protect_limit}) if len(slp_candidates) >= 2: max_slp = max(slp_candidates, key=lambda x: x["p"]) cur_price = float(price) if price else 0 if max_slp["p"] > cur_price: price = max_slp["p"] price_source = "PRIORITY_MATRIX_MAX" candidates_str = "|".join([f"{c['src']}:{c['p']}" for c in slp_candidates]) price_basis = f"SL003_MAX({candidates_str})" # Validation validation = "NO_SELL_ACTION" if action != "HOLD": try: price_val = float(price) if price else 0 validation = "SIGNAL_CONFIRMED" if is_finite(price_val) and price_val > 0 else "NO_SELL_PRICE" except (ValueError, TypeError): validation = "NO_SELL_PRICE" return { "action": action, "ratio_pct": ratio, "limit_price": price, "price_source": price_source, "price_basis": price_basis, "execution_window": execution_window, "order_type": order_type, "reason": reason, "validation": validation, "cash_preserve_style": cash_preserve_plan["style"], "cash_preserve_ratio": cash_preserve_plan["recommended_ratio"], "cash_preserve_reason": cash_preserve_plan["reasons"], }