From 2f0e294638efd802cc3e9d79628069c423d71961 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 22 Jun 2026 23:11:58 +0900 Subject: [PATCH] =?UTF-8?q?WBS-7:=20Phase=207=20=EB=B3=B4=EC=99=84=C2=B7?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20=E2=80=94=209=EA=B0=9C=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=99=84=EB=A3=8C,=20F05/F10=20=ED=8F=AC?= =?UTF-8?q?=ED=8C=85=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 항목 (9개): ✅ WBS-7.1: 캘리브레이션 도구 (data_gated, 실거래 축적 대기) ✅ WBS-7.2: T+5 단일 진실원천 통일 (spec/27_bch_calibration_runbook.yaml) ✅ WBS-7.3 부분: 9/15 GAS→Python 마이그레이션 - F02-F06 (priceBasis 로직) - F07 (점수 임계값) - F09, F11, F14, F15 (각종 게이트 및 판정 로직) ✅ WBS-7.4: Deprecated 별칭 정리 (2026-06-21 완료) ✅ WBS-7.5: 하드코딩 폴백 정규화 (3개 항목 → threshold 등록) ✅ WBS-7.6: 슬리피지 5bps 정규화 (EXECUTION_SLIPPAGE_BPS) ✅ WBS-7.7: E2E 통합테스트 (3개 항목 모두 PASS) ✅ WBS-7.9: Naver Cloudflare 403 모니터링 (구조화된 에러 처리) ✅ WBS-7.10: 공매도 수동 CSV 운영절차 명문화 미완료 항목 (2개, 다음 세션): 🔄 F05: calcExitSellAction_() 포팅 - formulas/execution_decision_v1.py 생성 (430줄) - 로직 완성 (calc_exit_sell_action, calc_cash_preservation_plan) - 상태: CONFIRMED_PORTABLE, 테스트 디버깅 필요 - 추정 시간: 2-3시간 (parity 테스트 완성, F10과 함께) 🔄 F10: runRouteFlow_() 포팅 - 242줄 함수, 6개 게이트 로직 (stop_breach, relative_stop, intraday_lock, heat, mean_reversion, ...) - 상태: CONFIRMED_PORTABLE (GAS API 미사용, 순수 함수) - 추정 시간: 2-3시간 (F05와 함께) 전체 테스트: 102/102 단위 테스트 PASS 다음 세션 계획: 1. F05/F10 parity 테스트 구축 및 PASS (각 ~50줄 테스트) 2. ledger 업데이트 (F05/F10 → DONE) 3. WBS-7.3 최종 종결 (15/15 완료 또는 최종 상태 확정) Co-Authored-By: Claude Haiku 4.5 --- formulas/execution_decision_v1.py | 433 ++++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 formulas/execution_decision_v1.py 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"], + }