using System; using System.Collections.Generic; namespace QuantEngine.Core.Domain { public class StopPriceResult { public double? StopPrice { get; set; } public string StopPriceStatus { get; set; } = "PASS"; public double? Atr20Pct { get; set; } public double? AtrMultiplier { get; set; } public List DataMissing { get; set; } = new List(); } public class StopActionLadderResult { public string Action { get; set; } = "REVIEW_HUMAN"; public string Reason { get; set; } = "NO_FORCED_EXIT"; public double QuantityPct { get; set; } public double Priority { get; set; } = 7; } public class HeatThresholdResult { public double HardBlock { get; set; } public double Halve { get; set; } } public static class ExitDecisions { public static StopPriceResult ComputeStopPriceCore( double? entryPrice, double? atr20, double? currentPrice = null, double? atrMultiplier = null) { var result = new StopPriceResult(); if (!entryPrice.HasValue) { result.StopPrice = null; result.StopPriceStatus = "NO_STOP_PRICE"; result.DataMissing.Add("entry_price"); return result; } if (!atr20.HasValue && !atrMultiplier.HasValue) { result.StopPrice = entryPrice.Value * 0.92; result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요"; result.DataMissing.Add("atr20"); return result; } if (!atrMultiplier.HasValue && (!currentPrice.HasValue || currentPrice.Value == 0)) { result.StopPrice = entryPrice.Value * 0.92; result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요"; if (!atr20.HasValue) result.DataMissing.Add("atr20"); if (!currentPrice.HasValue || currentPrice.Value == 0) result.DataMissing.Add("current_price"); return result; } if (!atrMultiplier.HasValue) { double atr20Pct = (atr20!.Value / currentPrice!.Value) * 100; atrMultiplier = atr20Pct >= 8 ? 2.0 : 1.5; result.Atr20Pct = atr20Pct; } else { result.Atr20Pct = (currentPrice.HasValue && currentPrice.Value != 0) ? (atr20!.Value / currentPrice.Value) * 100 : (double?)null; } result.AtrMultiplier = atrMultiplier; result.StopPrice = Math.Max(entryPrice.Value * 0.92, entryPrice.Value - atr20!.Value * atrMultiplier.Value); result.StopPriceStatus = "PASS"; return result; } public static StopActionLadderResult ComputeStopActionLadder(Dictionary context) { string GetString(string key) => context.TryGetValue(key, out var val) ? val?.ToString()?.ToUpper() ?? "" : ""; double GetDouble(string key) => context.TryGetValue(key, out var val) && double.TryParse(val?.ToString(), out var d) ? d : 0.0; int GetInt(string key, int def = 0) => context.TryGetValue(key, out var val) && int.TryParse(val?.ToString(), out var i) ? i : def; bool GetBool(string key) => context.TryGetValue(key, out var val) && bool.TryParse(val?.ToString(), out var b) && b; string timingAction = GetString("timingAction"); if (string.IsNullOrEmpty(timingAction)) timingAction = GetString("timing_action"); int rwPartial = GetInt("rw_partial"); int rwPartialExcludingRw2b = GetInt("rw_partial_excluding_rw2b"); string regimePrelim = GetString("REGIME_PRELIM"); if (string.IsNullOrEmpty(regimePrelim)) regimePrelim = GetString("regime_prelim"); double timingExitScore = GetDouble("timingExitScore"); if (timingExitScore == 0) timingExitScore = GetDouble("timing_exit_score"); double profitPct = GetDouble("profitPct"); if (profitPct == 0) profitPct = GetDouble("profit_pct"); int daysToTimeStop = GetInt("daysToTimeStop", 9999); if (daysToTimeStop == 9999) daysToTimeStop = GetInt("days_to_time_stop", 9999); bool trailingStopBreach = GetBool("trailingStopBreach") || GetBool("trailing_stop_breach"); bool rw2bFastTrack = GetBool("RW2b_5d_rapid_weakness") || GetBool("rw2b_5d_rapid_weakness"); if (timingAction == "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) { return new StopActionLadderResult { Action = "EXIT_100", Reason = timingAction == "STOP_OR_TIME_EXIT_READY" ? "STOP_OR_TIME_EXIT_READY" : "RW_EXIT_STRONG", QuantityPct = 100, Priority = 1 }; } if (regimePrelim == "RISK_OFF" || regimePrelim == "RISK_OFF_CANDIDATE") { return new StopActionLadderResult { Action = "REGIME_TRIM_50", Reason = regimePrelim == "RISK_OFF" ? "REGIME_RISK_OFF" : "REGIME_RISK_OFF_CANDIDATE", QuantityPct = 50, Priority = 2 }; } if (rw2bFastTrack && rwPartialExcludingRw2b >= 1) { return new StopActionLadderResult { Action = "TRIM_50", Reason = "RW2B_FAST_TRACK", QuantityPct = 50, Priority = 2.5 }; } if (rwPartial >= 3 || timingExitScore >= 75) { return new StopActionLadderResult { Action = "TRIM_70", Reason = rwPartial >= 3 ? "RW_EXIT" : "TIMING_EXIT_SCORE", QuantityPct = 70, Priority = 3 }; } if (trailingStopBreach || rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) { return new StopActionLadderResult { Action = "TRIM_50", Reason = trailingStopBreach ? "TRAILING_STOP_BREACH" : "RW_OR_TIMING_EXIT", QuantityPct = 50, Priority = 4 }; } if (profitPct >= 10) { return new StopActionLadderResult { Action = "TAKE_PROFIT_TIER1", Reason = "PROFIT_PCT_THRESHOLD", QuantityPct = 25, Priority = 5 }; } if (daysToTimeStop <= 0) { return new StopActionLadderResult { Action = "TIME_EXIT_100", Reason = "TIME_STOP_EXPIRED", QuantityPct = 100, Priority = 6 }; } return new StopActionLadderResult(); } public static HeatThresholdResult ComputeDynamicHeatThresholds(string regime) { string r = regime?.ToUpper() ?? ""; if (r.Contains("EVENT_SHOCK")) { return new HeatThresholdResult { HardBlock = 5.0, Halve = 3.5 }; } if (r.Contains("RISK_OFF")) { return new HeatThresholdResult { HardBlock = 7.0, Halve = 5.0 }; } if (r.Contains("SECULAR_LEADER") && r.Contains("RISK_ON")) { return new HeatThresholdResult { HardBlock = 13.0, Halve = 9.0 }; } if (r.Contains("RISK_ON")) { return new HeatThresholdResult { HardBlock = 12.0, Halve = 8.5 }; } return new HeatThresholdResult { HardBlock = 10.0, Halve = 7.0 }; } } }