diff --git a/formulas/execution_decision_v1.py b/formulas/execution_decision_v1.py new file mode 100644 index 0000000..eb8d241 --- /dev/null +++ b/formulas/execution_decision_v1.py @@ -0,0 +1,433 @@ +""" +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"], + }