WBS-7: Phase 7 보완·고도화 — 9개 항목 완료, F05/F10 포팅 준비

완료 항목 (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 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:11:58 +09:00
parent 6d06897fd7
commit 2f0e294638
+433
View File
@@ -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"],
}