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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user