feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)

주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:20:14 +09:00
commit ee3e799de1
1474 changed files with 176087 additions and 0 deletions
+564
View File
@@ -0,0 +1,564 @@
from __future__ import annotations
import math
from typing import Any
KRX_TICK_TABLE: tuple[tuple[float, int], ...] = (
(2_000, 1),
(5_000, 5),
(20_000, 10),
(50_000, 50),
(200_000, 100),
(500_000, 500),
(math.inf, 1000),
)
def krx_tick_unit(price: float) -> int:
for threshold, tick in KRX_TICK_TABLE:
if price < threshold:
return tick
return 1000
def normalize_tick(price: float) -> int:
tick = krx_tick_unit(price)
return int(math.floor(price / tick) * tick)
def compute_stop_price_core(
entry_price: float | None,
atr20: float | None,
current_price: float | None = None,
atr_multiplier: float | None = None,
) -> dict[str, Any]:
if entry_price is None:
return {
"stop_price": None,
"stop_price_status": "NO_STOP_PRICE",
"data_missing": ["entry_price"],
}
if atr20 is None and atr_multiplier is None:
return {
"stop_price": entry_price * 0.92,
"stop_price_status": "DATA_MISSING — 하네스 업데이트 필요",
"data_missing": ["atr20"],
}
if atr_multiplier is None and current_price in (None, 0):
return {
"stop_price": entry_price * 0.92,
"stop_price_status": "DATA_MISSING — 하네스 업데이트 필요",
"data_missing": [name for name, value in (("atr20", atr20), ("current_price", current_price)) if value in (None, 0)],
}
if atr_multiplier is None:
atr20_pct = (atr20 / current_price) * 100
atr_multiplier = 2.0 if atr20_pct >= 8 else 1.5
else:
atr20_pct = (atr20 / current_price) * 100 if current_price not in (None, 0) else None
return {
"stop_price": max(entry_price * 0.92, entry_price - atr20 * atr_multiplier),
"stop_price_status": "PASS",
"atr20_pct": atr20_pct,
"atr_multiplier": atr_multiplier,
}
def compute_stop_action_ladder(context: dict[str, Any]) -> dict[str, Any]:
timing_action = str(context.get("timingAction") or context.get("timing_action") or "").upper()
rw_partial = int(context.get("rw_partial") or 0)
rw_partial_excluding_rw2b = int(context.get("rw_partial_excluding_rw2b") or 0)
regime_prelim = str(context.get("REGIME_PRELIM") or context.get("regime_prelim") or "").upper()
timing_exit_score = float(context.get("timingExitScore") or context.get("timing_exit_score") or 0.0)
profit_pct = float(context.get("profitPct") or context.get("profit_pct") or 0.0)
days_to_time_stop = int(context.get("daysToTimeStop") or context.get("days_to_time_stop") or 9999)
trailing_stop_breach = bool(context.get("trailingStopBreach") or context.get("trailing_stop_breach") or False)
rw2b_fast_track = bool(context.get("RW2b_5d_rapid_weakness") or context.get("rw2b_5d_rapid_weakness") or False)
if timing_action == "STOP_OR_TIME_EXIT_READY" or rw_partial >= 4:
return {
"action": "EXIT_100",
"reason": "STOP_OR_TIME_EXIT_READY" if timing_action == "STOP_OR_TIME_EXIT_READY" else "RW_EXIT_STRONG",
"quantity_pct": 100,
"priority": 1,
}
if regime_prelim in {"RISK_OFF", "RISK_OFF_CANDIDATE"}:
return {
"action": "REGIME_TRIM_50",
"reason": "REGIME_RISK_OFF" if regime_prelim == "RISK_OFF" else "REGIME_RISK_OFF_CANDIDATE",
"quantity_pct": 50,
"priority": 2,
}
if rw2b_fast_track and rw_partial_excluding_rw2b >= 1:
return {
"action": "TRIM_50",
"reason": "RW2B_FAST_TRACK",
"quantity_pct": 50,
"priority": 2.5,
}
if rw_partial >= 3 or timing_exit_score >= 75:
return {
"action": "TRIM_70",
"reason": "RW_EXIT" if rw_partial >= 3 else "TIMING_EXIT_SCORE",
"quantity_pct": 70,
"priority": 3,
}
if trailing_stop_breach or rw_partial >= 2 or (rw_partial >= 1 and timing_exit_score >= 50):
return {
"action": "TRIM_50",
"reason": "TRAILING_STOP_BREACH" if trailing_stop_breach else "RW_OR_TIMING_EXIT",
"quantity_pct": 50,
"priority": 4,
}
if profit_pct >= 10:
return {
"action": "TAKE_PROFIT_TIER1",
"reason": "PROFIT_PCT_THRESHOLD",
"quantity_pct": 25,
"priority": 5,
}
if days_to_time_stop <= 0:
return {
"action": "TIME_EXIT_100",
"reason": "TIME_STOP_EXPIRED",
"quantity_pct": 100,
"priority": 6,
}
return {
"action": "REVIEW_HUMAN",
"reason": "NO_FORCED_EXIT",
"quantity_pct": 0,
"priority": 7,
}
def compute_dynamic_heat_thresholds(regime: str | None) -> dict[str, float]:
r = str(regime or "").upper()
if "EVENT_SHOCK" in r:
return {"hardBlock": 5.0, "halve": 3.5}
if "RISK_OFF" in r:
return {"hardBlock": 7.0, "halve": 5.0}
if "SECULAR_LEADER" in r and "RISK_ON" in r:
return {"hardBlock": 13.0, "halve": 9.0}
if "RISK_ON" in r:
return {"hardBlock": 12.0, "halve": 8.5}
return {"hardBlock": 10.0, "halve": 7.0}
def compute_cash_shortfall_harness(
as_result: dict[str, Any],
total_asset: float,
cash_floor_info: dict[str, Any],
mrs_score: float,
) -> dict[str, Any]:
asset = total_asset if math.isfinite(total_asset) and total_asset > 0 else 0.0
d2_krw = float(as_result.get("settlementCashD2Krw") or 0.0)
min_pct = float(cash_floor_info.get("minPct") or 0.0)
target_cash_pct = max(5 + (mrs_score / 10) * 15, min_pct)
return {
"cash_current_pct_d2": round((d2_krw / asset * 100), 2) if asset > 0 else 0,
"cash_target_pct": target_cash_pct,
"cash_shortfall_min_krw": max(0, round(asset * min_pct / 100 - d2_krw)),
"cash_shortfall_target_krw": max(0, round(asset * target_cash_pct / 100 - d2_krw)),
}
def compute_timing_decision(ctx: dict[str, Any]) -> dict[str, Any]:
reasons: list[str] = []
entry_score = 0
exit_score = 0
entry_gate = str(ctx.get("entryModeGate") or "")
entry_mode = str(ctx.get("entryMode") or "")
leader_gate = str(ctx.get("leaderGate") or "")
ac_gate = str(ctx.get("acGate") or "")
exit_signal = str(ctx.get("exitSignalDetail") or "")
flow_credit = ctx.get("flowCredit")
leader_total = ctx.get("leaderTotal")
rw_partial = ctx.get("rwPartial")
rsi14 = ctx.get("rsi14")
disparity = ctx.get("disparity")
ma20_slope = ctx.get("ma20Slope")
spread_pct = ctx.get("spreadPct")
avg_trade_value_5d = ctx.get("avgTradeValue5D")
profit_pct = ctx.get("profitPct")
days_to_time_stop = ctx.get("daysToTimeStop")
if entry_gate == "PASS":
entry_score += 25
reasons.append(f"entry_{entry_mode}")
elif entry_gate == "BLOCK":
entry_score -= 25
reasons.append("entry_block")
if isinstance(leader_total, (int, float)) and math.isfinite(float(leader_total)):
if leader_total >= 4:
entry_score += 20
reasons.append("leader_scan>=4")
elif leader_total >= 3:
entry_score += 10
reasons.append("leader_watch")
if leader_gate in {"PASS", "EXPLORE_CANDIDATE"}:
entry_score += 10
if isinstance(flow_credit, (int, float)) and math.isfinite(float(flow_credit)):
if flow_credit >= 0.7:
entry_score += 20
reasons.append("flow_strong")
elif flow_credit >= 0.4:
entry_score += 10
reasons.append("flow_partial")
if ac_gate == "CLEAR":
entry_score += 15
reasons.append("anti_climax_clear")
elif ac_gate == "CAUTION":
entry_score += 5
reasons.append("anti_climax_caution")
elif ac_gate == "BLOCK":
entry_score -= 35
exit_score += 15
reasons.append("anti_climax_block")
if isinstance(ma20_slope, (int, float)) and math.isfinite(float(ma20_slope)):
if ma20_slope > 0:
entry_score += 8
else:
entry_score -= 8
exit_score += 8
reasons.append("ma20_down")
if isinstance(disparity, (int, float)) and math.isfinite(float(disparity)):
if -5 <= disparity <= 4:
entry_score += 10
elif 4 < disparity <= 8:
entry_score += 5
elif disparity > 12:
entry_score -= 25
exit_score += 20
reasons.append("overextended")
elif disparity < -10:
entry_score -= 10
exit_score += 10
reasons.append("trend_damage")
if isinstance(rsi14, (int, float)) and math.isfinite(float(rsi14)):
if 40 <= rsi14 <= 65:
entry_score += 10
elif 65 < rsi14 <= 72:
entry_score += 4
elif rsi14 > 75:
entry_score -= 25
exit_score += 20
reasons.append("rsi_overbought")
elif rsi14 < 35:
entry_score -= 5
exit_score += 8
reasons.append("weak_rsi")
if (
isinstance(avg_trade_value_5d, (int, float))
and math.isfinite(float(avg_trade_value_5d))
and avg_trade_value_5d >= 50
and (not isinstance(spread_pct, (int, float)) or not math.isfinite(float(spread_pct)) or spread_pct <= 0.8)
):
entry_score += 10
else:
entry_score -= 15
reasons.append("liquidity_or_spread_fail")
if isinstance(rw_partial, (int, float)) and math.isfinite(float(rw_partial)):
exit_score += min(100, max(0, int(rw_partial) * 25))
if exit_signal:
exit_score += len([part for part in exit_signal.split("|") if part]) * 10
if isinstance(days_to_time_stop, (int, float)) and 0 <= float(days_to_time_stop) <= 7:
exit_score += 20
reasons.append("time_stop_near")
if isinstance(profit_pct, (int, float)) and profit_pct >= 10:
exit_score += 15
reasons.append("profit_protect_zone")
entry_score = max(0, min(100, round(entry_score)))
exit_score = max(0, min(100, round(exit_score)))
action = "HOLD_NO_TIMING_EDGE"
atr20 = ctx.get("atr20")
price_status = str(ctx.get("priceStatus") or "")
if price_status != "PRICE_OK" or not isinstance(atr20, (int, float)) or not math.isfinite(float(atr20)):
action = "OBSERVE_DATA_MISSING"
elif exit_score >= 75 or (isinstance(rw_partial, (int, float)) and rw_partial >= 4):
action = "STOP_OR_TIME_EXIT_READY"
elif exit_score >= 50 or (isinstance(rw_partial, (int, float)) and rw_partial >= 3):
action = "EXIT_REVIEW"
elif entry_gate == "BLOCK" or ac_gate == "BLOCK" or entry_mode == "OVERBOUGHT":
action = "NO_BUY_OVERHEATED"
elif entry_score >= 75 and entry_gate == "PASS" and isinstance(leader_total, (int, float)) and leader_total >= 4:
action = "BUY_BREAKOUT_PILOT_ONLY" if entry_mode == "BREAKOUT" else "BUY_STAGE1_READY"
elif entry_score >= 60 and entry_gate == "PASS":
action = "BUY_BREAKOUT_PILOT_ONLY" if entry_mode == "BREAKOUT" else "BUY_PULLBACK_WAIT"
elif (
(isinstance(leader_total, (int, float)) and leader_total >= 3)
or (isinstance(flow_credit, (int, float)) and flow_credit >= 0.4)
):
action = "WATCH_TIMING_SETUP"
return {
"entry_score": entry_score,
"exit_score": exit_score,
"action": action,
"reason": "|".join(reasons[:6]),
}
def compute_sell_decision(ctx: dict[str, Any]) -> dict[str, Any]:
close = ctx.get("close")
stop_price = ctx.get("stopPrice")
trailing_stop = ctx.get("trailingStop")
tp1_price = ctx.get("tp1Price")
tp2_price = ctx.get("tp2Price")
profit_pct = ctx.get("profitPct")
rw_partial = ctx.get("rwPartial")
timing_exit_score = ctx.get("timingExitScore")
days_to_time_stop = ctx.get("daysToTimeStop")
timing_action = str(ctx.get("timingAction") or "")
regime = str(ctx.get("regime") or "")
atr20 = ctx.get("atr20")
close_f = float(close) if isinstance(close, (int, float)) else float("nan")
stop_f = float(stop_price) if isinstance(stop_price, (int, float)) else float("nan")
trailing_f = float(trailing_stop) if isinstance(trailing_stop, (int, float)) else float("nan")
tp1_f = float(tp1_price) if isinstance(tp1_price, (int, float)) else float("nan")
tp2_f = float(tp2_price) if isinstance(tp2_price, (int, float)) else float("nan")
profit_f = float(profit_pct) if isinstance(profit_pct, (int, float)) else float("nan")
rw_f = float(rw_partial) if isinstance(rw_partial, (int, float)) else float("nan")
timing_exit_f = float(timing_exit_score) if isinstance(timing_exit_score, (int, float)) else float("nan")
days_f = float(days_to_time_stop) if isinstance(days_to_time_stop, (int, float)) else float("nan")
atr_f = float(atr20) if isinstance(atr20, (int, float)) else float("nan")
action = "HOLD"
ratio = 0
reason = ""
price: Any = ""
price_source = ""
price_basis = ""
execution_window = ""
order_type = ""
stop_candidate = trailing_f if math.isfinite(trailing_f) and trailing_f > 0 else stop_f
if not (math.isfinite(stop_candidate) and stop_candidate > 0) and math.isfinite(close_f) and close_f > 0:
stop_candidate = close_f * 0.995
protective_limit = round(min(close_f * 0.995, stop_candidate if math.isfinite(stop_candidate) else close_f * 0.995)) if math.isfinite(close_f) and close_f > 0 else ""
atr_buffer = atr_f * 0.3 if math.isfinite(atr_f) and atr_f > 0 else (close_f * 0.005 if math.isfinite(close_f) else 0)
close_protect_limit = round(close_f - atr_buffer) if math.isfinite(close_f) and close_f > 0 else ""
if timing_action == "STOP_OR_TIME_EXIT_READY" or (math.isfinite(rw_f) and rw_f >= 4):
action = "EXIT_100"
ratio = 100
reason = "RW_EXIT_STRONG" if math.isfinite(rw_f) and rw_f >= 4 else "STOP_OR_TIME_EXIT_READY"
price = protective_limit
price_source = "TRAILING_STOP" if math.isfinite(trailing_f) else "STOP_OR_CLOSE"
price_basis = "TRAILING_STOP_TRIGGER" if math.isfinite(trailing_f) else "STOP_OR_CLOSE_PROTECT"
execution_window = "INTRADAY_ON_TRIGGER"
order_type = "PROTECTIVE_LIMIT_SELL"
elif math.isfinite(rw_f) and rw_f >= 3 or (math.isfinite(timing_exit_f) and timing_exit_f >= 75):
action = "TRIM_70"
ratio = 70
reason = "RW_EXIT" if math.isfinite(rw_f) and rw_f >= 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"
elif math.isfinite(trailing_f) and trailing_f > 0 and math.isfinite(close_f) and close_f <= trailing_f:
action = "TRAILING_STOP_BREACH"
ratio = 70
reason = "TRAILING_STOP_PRICE_BREACH"
price = round(trailing_f)
price_source = "TRAILING_STOP_PRICE"
price_basis = "TRAILING_STOP_TRIGGER"
execution_window = "INTRADAY_ON_TRIGGER"
order_type = "PROTECTIVE_LIMIT_SELL"
elif (math.isfinite(rw_f) and rw_f >= 2) or (math.isfinite(rw_f) and rw_f >= 1 and math.isfinite(timing_exit_f) and timing_exit_f >= 50):
action = "TRIM_50"
ratio = 50
reason = "RW_REVIEW" if math.isfinite(rw_f) and rw_f >= 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"
elif math.isfinite(rw_f) and rw_f >= 1 and math.isfinite(timing_exit_f) and timing_exit_f >= 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"
elif math.isfinite(rw_f) and rw_f >= 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"
elif math.isfinite(profit_f) and profit_f >= 50:
action = "PROFIT_TRIM_50"
ratio = 50
reason = "PROFIT_PROTECT_50"
price = round(tp2_f) if math.isfinite(tp2_f) and tp2_f > 0 else close_protect_limit
price_source = "TP2_PRICE" if math.isfinite(tp2_f) else "CLOSE_PROFIT_PROTECT"
price_basis = "TAKE_PROFIT_TIER2_PRICE" if math.isfinite(tp2_f) else "PRIOR_CLOSE_X_0.998"
execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"
order_type = "LIMIT_SELL"
elif math.isfinite(profit_f) and profit_f >= 30:
action = "PROFIT_TRIM_35"
ratio = 35
reason = "PROFIT_PROTECT_30"
price = round(tp2_f) if math.isfinite(tp2_f) and tp2_f > 0 else close_protect_limit
price_source = "TP2_PRICE" if math.isfinite(tp2_f) else "CLOSE_PROFIT_PROTECT"
price_basis = "TAKE_PROFIT_TIER2_PRICE" if math.isfinite(tp2_f) else "PRIOR_CLOSE_X_0.998"
execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"
order_type = "LIMIT_SELL"
elif math.isfinite(profit_f) and profit_f >= 20:
action = "PROFIT_TRIM_25"
ratio = 25
reason = "PROFIT_PROTECT_20"
price = round(tp1_f) if math.isfinite(tp1_f) and tp1_f > 0 else close_protect_limit
price_source = "TP1_PRICE" if math.isfinite(tp1_f) else "CLOSE_PROFIT_PROTECT"
price_basis = "TAKE_PROFIT_TIER1_PRICE" if math.isfinite(tp1_f) else "PRIOR_CLOSE_X_0.998"
execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"
order_type = "LIMIT_SELL"
elif math.isfinite(profit_f) and profit_f >= 10:
action = "TAKE_PROFIT_TIER1"
ratio = 25
reason = "TP1_PROFIT_10PCT"
price = round(tp1_f) if math.isfinite(tp1_f) and tp1_f > 0 else close_protect_limit
price_source = "TP1_PRICE" if math.isfinite(tp1_f) else "CLOSE_PROFIT_PROTECT"
price_basis = "TAKE_PROFIT_TIER1_PRICE" if math.isfinite(tp1_f) else "PRIOR_CLOSE_X_0.998"
execution_window = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"
order_type = "LIMIT_SELL"
elif math.isfinite(days_f) and days_f <= 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 math.isfinite(days_f) and days_f <= 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 math.isfinite(days_f) and days_f <= 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"
validation = "NO_SELL_ACTION"
if action != "HOLD":
validation = "SIGNAL_CONFIRMED" if isinstance(price, (int, float)) and float(price) > 0 else "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_ratio": 0,
"cash_preserve_reason": [],
}
def compute_final_decision(ctx: dict[str, Any]) -> dict[str, Any]:
sell_action = str(ctx.get("sellAction") or "HOLD")
sell_validation = str(ctx.get("sellValidation") or "")
allowed_action = str(ctx.get("allowedAction") or "")
timing_action = str(ctx.get("timingAction") or "")
timing_entry = ctx.get("timingScoreEntry")
timing_exit = ctx.get("timingScoreExit")
ss001_total = ctx.get("ss001Total")
flow_credit = ctx.get("flowCredit")
leader_total = ctx.get("leaderTotal")
rw_partial = ctx.get("rwPartial")
profit_pct = ctx.get("profitPct")
days_to_time_stop = ctx.get("daysToTimeStop")
weight_pct = ctx.get("weightPct")
ac_gate = str(ctx.get("acGate") or "")
liquidity_status = str(ctx.get("liquidityStatus") or "")
spread_status = str(ctx.get("spreadStatus") or "")
dart_risk = bool(ctx.get("dartRisk"))
missing_fields = str(ctx.get("missingFields") or "")
final_action = "HOLD"
action_priority = 99
decision_source = "RULE_ENGINE"
if sell_action != "HOLD" and sell_validation == "SIGNAL_CONFIRMED":
final_action = "SELL_READY"
action_priority = 10
elif allowed_action == "EXIT_SIGNAL" or timing_action == "STOP_OR_TIME_EXIT_READY":
final_action = "EXIT_SIGNAL"
action_priority = 28
elif allowed_action == "REVIEW_EXIT" or timing_action == "EXIT_REVIEW":
final_action = "EXIT_REVIEW"
action_priority = 32
elif timing_action == "NO_BUY_OVERHEATED" and not dart_risk:
final_action = "NO_BUY_OVERHEATED"
action_priority = 50
elif allowed_action == "BUY_STAGE1_READY" or timing_action == "BUY_STAGE1_READY":
final_action = "BUY_STAGE1_READY"
action_priority = 60
elif allowed_action == "BUY_BREAKOUT_PILOT_ONLY" or timing_action == "BUY_BREAKOUT_PILOT_ONLY":
final_action = "BUY_BREAKOUT_PILOT_ONLY"
action_priority = 70
elif allowed_action == "BUY_PULLBACK_WAIT" or timing_action == "BUY_PULLBACK_WAIT":
final_action = "BUY_PULLBACK_WAIT"
action_priority = 80
elif allowed_action == "WATCH_CANDIDATE":
final_action = "WATCH_TIMING_SETUP"
action_priority = 90
if missing_fields:
decision_source = "RULE_ENGINE_WITH_MISSING_DATA"
def _finite(value: Any) -> bool:
return isinstance(value, (int, float)) and math.isfinite(float(value))
time_stop_urgency = max(0, 20 - min(20, float(days_to_time_stop) * 3)) if _finite(days_to_time_stop) and days_to_time_stop >= 0 else 0
overweight_penalty = 15 if _finite(weight_pct) and weight_pct > 7 else 0
overheat_penalty = 30 if ac_gate == "BLOCK" else 10 if ac_gate == "CAUTION" else 0
liquidity_penalty = 15 if liquidity_status in {"LOW", "DATA_MISSING"} or spread_status in {"BLOCK", "WIDE", "QUOTE_NO_MATCH"} else 0
if action_priority <= 40:
priority_score = (float(timing_exit) if _finite(timing_exit) else 0) * 0.35 + (float(rw_partial) if _finite(rw_partial) else 0) * 15 + max(0, float(profit_pct) if _finite(profit_pct) else 0) * 0.30 + time_stop_urgency + overweight_penalty
elif 50 <= action_priority <= 80:
priority_score = (float(timing_entry) if _finite(timing_entry) else 0) * 0.35 + (float(ss001_total) if _finite(ss001_total) else 0) * 0.30 + (float(flow_credit) if _finite(flow_credit) else 0) * 20 + (float(leader_total) if _finite(leader_total) else 0) * 5 - overheat_penalty - liquidity_penalty
else:
priority_score = (float(timing_entry) if _finite(timing_entry) else 0) * 0.20 + (float(timing_exit) if _finite(timing_exit) else 0) * 0.20 + (float(flow_credit) if _finite(flow_credit) else 0) * 10
return {
"final_action": final_action,
"action_priority": action_priority,
"priority_score": float(max(0, priority_score)),
"decision_source": decision_source,
}