using System; using System.Collections.Generic; namespace QuantEngine.Core.Domain { public class TimingDecisionResult { public double EntryScore { get; set; } public double ExitScore { get; set; } public string Action { get; set; } = "HOLD_NO_TIMING_EDGE"; public string Reason { get; set; } = string.Empty; } public class SellDecisionResult { public string Action { get; set; } = "HOLD"; public double RatioPct { get; set; } public double? LimitPrice { get; set; } public string PriceSource { get; set; } = string.Empty; public string PriceBasis { get; set; } = string.Empty; public string ExecutionWindow { get; set; } = string.Empty; public string OrderType { get; set; } = string.Empty; public string Reason { get; set; } = string.Empty; public string Validation { get; set; } = "NO_SELL_ACTION"; public string CashPreserveStyle { get; set; } = string.Empty; public double CashPreserveRatio { get; set; } public List CashPreserveReason { get; set; } = new List(); } public class FinalDecisionResult { public string FinalAction { get; set; } = "HOLD"; public int ActionPriority { get; set; } = 99; public double PriorityScore { get; set; } public string DecisionSource { get; set; } = "RULE_ENGINE"; } public class CashShortfallResult { public double CashCurrentPctD2 { get; set; } public double CashTargetPct { get; set; } public double CashShortfallMinKrw { get; set; } public double CashShortfallTargetKrw { get; set; } } public class CashRecoveryPlanItem { public string Ticker { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public int Qty { get; set; } public double? LimitPrice { get; set; } public string PreserveStyle { get; set; } = string.Empty; public double PreserveRatio { get; set; } public double ExpectedKrw { get; set; } } public class CashRecoveryPlanResult { public List SellSequence { get; set; } = new List(); public double ExpectedTotalKrw { get; set; } public double CashShortfallMinKrw { get; set; } public bool ShortfallMet { get; set; } public int ItemsNeeded { get; set; } } public static class FormulaEngine { private static bool IsValidNumber(double? value) => value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value); public static TimingDecisionResult ComputeTimingDecision(Dictionary ctx) { var reasons = new List(); double entryScore = 0; double exitScore = 0; string entryGate = GetString(ctx, "entryModeGate"); string entryMode = GetString(ctx, "entryMode"); string leaderGate = GetString(ctx, "leaderGate"); string acGate = GetString(ctx, "acGate"); string exitSignal = GetString(ctx, "exitSignalDetail"); double? flowCredit = GetNullableDouble(ctx, "flowCredit"); double? leaderTotal = GetNullableDouble(ctx, "leaderTotal"); double? rwPartial = GetNullableDouble(ctx, "rwPartial"); double? rsi14 = GetNullableDouble(ctx, "rsi14"); double? disparity = GetNullableDouble(ctx, "disparity"); double? ma20Slope = GetNullableDouble(ctx, "ma20Slope"); double? spreadPct = GetNullableDouble(ctx, "spreadPct"); double? avgTradeValue5D = GetNullableDouble(ctx, "avgTradeValue5D"); double? profitPct = GetNullableDouble(ctx, "profitPct"); double? daysToTimeStop = GetNullableDouble(ctx, "daysToTimeStop"); if (entryGate == "PASS") { entryScore += 25; reasons.Add($"entry_{entryMode}"); } else if (entryGate == "BLOCK") { entryScore -= 25; reasons.Add("entry_block"); } if (IsValidNumber(leaderTotal)) { if (leaderTotal!.Value >= 4) { entryScore += 20; reasons.Add("leader_scan>=4"); } else if (leaderTotal.Value >= 3) { entryScore += 10; reasons.Add("leader_watch"); } } if (leaderGate == "PASS" || leaderGate == "EXPLORE_CANDIDATE") { entryScore += 10; } if (IsValidNumber(flowCredit)) { if (flowCredit!.Value >= 0.7) { entryScore += 20; reasons.Add("flow_strong"); } else if (flowCredit.Value >= 0.4) { entryScore += 10; reasons.Add("flow_partial"); } } if (acGate == "CLEAR") { entryScore += 15; reasons.Add("anti_climax_clear"); } else if (acGate == "CAUTION") { entryScore += 5; reasons.Add("anti_climax_caution"); } else if (acGate == "BLOCK") { entryScore -= 35; exitScore += 15; reasons.Add("anti_climax_block"); } if (IsValidNumber(ma20Slope)) { if (ma20Slope!.Value > 0) { entryScore += 8; } else { entryScore -= 8; exitScore += 8; reasons.Add("ma20_down"); } } if (IsValidNumber(disparity)) { if (disparity!.Value >= -5 && disparity.Value <= 4) { entryScore += 10; } else if (disparity.Value > 4 && disparity.Value <= 8) { entryScore += 5; } else if (disparity.Value > 12) { entryScore -= 25; exitScore += 20; reasons.Add("overextended"); } else if (disparity.Value < -10) { entryScore -= 10; exitScore += 10; reasons.Add("trend_damage"); } } if (IsValidNumber(rsi14)) { if (rsi14!.Value >= 40 && rsi14.Value <= 65) { entryScore += 10; } else if (rsi14.Value > 65 && rsi14.Value <= 72) { entryScore += 4; } else if (rsi14.Value > 75) { entryScore -= 25; exitScore += 20; reasons.Add("rsi_overbought"); } else if (rsi14.Value < 35) { entryScore -= 5; exitScore += 8; reasons.Add("weak_rsi"); } } if (IsValidNumber(avgTradeValue5D) && avgTradeValue5D!.Value >= 50 && (!IsValidNumber(spreadPct) || spreadPct!.Value <= 0.8)) { entryScore += 10; } else { entryScore -= 15; reasons.Add("liquidity_or_spread_fail"); } if (IsValidNumber(rwPartial)) { exitScore += Math.Min(100.0, Math.Max(0.0, (int)rwPartial!.Value * 25.0)); } if (!string.IsNullOrEmpty(exitSignal)) { var parts = exitSignal.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); exitScore += parts.Length * 10; } if (IsValidNumber(daysToTimeStop) && daysToTimeStop!.Value >= 0 && daysToTimeStop.Value <= 7) { exitScore += 20; reasons.Add("time_stop_near"); } if (IsValidNumber(profitPct) && profitPct!.Value >= 10) { exitScore += 15; reasons.Add("profit_protect_zone"); } entryScore = Math.Max(0.0, Math.Min(100.0, Math.Round(entryScore))); exitScore = Math.Max(0.0, Math.Min(100.0, Math.Round(exitScore))); string action = "HOLD_NO_TIMING_EDGE"; double? atr20 = GetNullableDouble(ctx, "atr20"); string priceStatus = GetString(ctx, "priceStatus"); if (priceStatus != "PRICE_OK" || !IsValidNumber(atr20)) { action = "OBSERVE_DATA_MISSING"; } else if (exitScore >= 75 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 4)) { action = "STOP_OR_TIME_EXIT_READY"; } else if (exitScore >= 50 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 3)) { action = "EXIT_REVIEW"; } else if (entryGate == "BLOCK" || acGate == "BLOCK" || entryMode == "OVERBOUGHT") { action = "NO_BUY_OVERHEATED"; } else if (entryScore >= 75 && entryGate == "PASS" && leaderTotal.HasValue && leaderTotal.Value >= 4) { action = entryMode == "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_STAGE1_READY"; } else if (entryScore >= 60 && entryGate == "PASS") { action = entryMode == "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_PULLBACK_WAIT"; } else if ((leaderTotal.HasValue && leaderTotal.Value >= 3) || (flowCredit.HasValue && flowCredit.Value >= 0.4)) { action = "WATCH_TIMING_SETUP"; } // Slice down reasons to max 6 elements to align with Python output int takeCount = Math.Min(6, reasons.Count); string reasonStr = string.Join("|", reasons.GetRange(0, takeCount)); return new TimingDecisionResult { EntryScore = entryScore, ExitScore = exitScore, Action = action, Reason = reasonStr }; } public static SellDecisionResult ComputeSellDecision(Dictionary ctx) { double? close = GetNullableDouble(ctx, "close"); double? stopPrice = GetNullableDouble(ctx, "stopPrice"); double? trailingStop = GetNullableDouble(ctx, "trailingStop"); double? tp1Price = GetNullableDouble(ctx, "tp1Price"); double? tp2Price = GetNullableDouble(ctx, "tp2Price"); double? profitPct = GetNullableDouble(ctx, "profitPct"); double? rwPartial = GetNullableDouble(ctx, "rwPartial"); double? timingExitScore = GetNullableDouble(ctx, "timingExitScore"); double? daysToTimeStop = GetNullableDouble(ctx, "daysToTimeStop"); string timingAction = GetString(ctx, "timingAction"); double? atr20 = GetNullableDouble(ctx, "atr20"); double closeF = close ?? double.NaN; double stopF = stopPrice ?? double.NaN; double trailingF = trailingStop ?? double.NaN; double tp1F = tp1Price ?? double.NaN; double tp2F = tp2Price ?? double.NaN; double profitF = profitPct ?? double.NaN; double rwF = rwPartial ?? double.NaN; double timingExitF = timingExitScore ?? double.NaN; double daysF = daysToTimeStop ?? double.NaN; double atrF = atr20 ?? double.NaN; string action = "HOLD"; double ratio = 0; string reason = ""; double? price = null; string priceSource = ""; string priceBasis = ""; string executionWindow = ""; string orderType = ""; double stopCandidate = (double.IsNaN(trailingF) || trailingF <= 0) ? stopF : trailingF; if ((double.IsNaN(stopCandidate) || stopCandidate <= 0) && !double.IsNaN(closeF) && closeF > 0) { stopCandidate = closeF * 0.995; } double? protectiveLimit = null; if (!double.IsNaN(closeF) && closeF > 0) { double candidate = (double.IsNaN(stopCandidate) || stopCandidate <= 0) ? closeF * 0.995 : stopCandidate; protectiveLimit = Math.Round(Math.Min(closeF * 0.995, candidate)); } double atrBuffer = (!double.IsNaN(atrF) && atrF > 0) ? atrF * 0.3 : (double.IsNaN(closeF) ? 0 : closeF * 0.005); double? closeProtectLimit = !double.IsNaN(closeF) && closeF > 0 ? (double?)Math.Round(closeF - atrBuffer) : null; if (timingAction == "STOP_OR_TIME_EXIT_READY" || (!double.IsNaN(rwF) && rwF >= 4)) { action = "EXIT_100"; ratio = 100; reason = (!double.IsNaN(rwF) && rwF >= 4) ? "RW_EXIT_STRONG" : "STOP_OR_TIME_EXIT_READY"; price = protectiveLimit; priceSource = !double.IsNaN(trailingF) ? "TRAILING_STOP" : "STOP_OR_CLOSE"; priceBasis = !double.IsNaN(trailingF) ? "TRAILING_STOP_TRIGGER" : "STOP_OR_CLOSE_PROTECT"; executionWindow = "INTRADAY_ON_TRIGGER"; orderType = "PROTECTIVE_LIMIT_SELL"; } else if ((!double.IsNaN(rwF) && rwF >= 3) || (!double.IsNaN(timingExitF) && timingExitF >= 75)) { action = "TRIM_70"; ratio = 70; reason = (!double.IsNaN(rwF) && rwF >= 3) ? "RW_EXIT" : "TIMING_EXIT_SCORE"; price = protectiveLimit; priceSource = "RISK_REDUCTION"; priceBasis = "RISK_REDUCTION_CLOSE_PROTECT"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "PROTECTIVE_LIMIT_SELL"; } else if (!double.IsNaN(trailingF) && trailingF > 0 && !double.IsNaN(closeF) && closeF <= trailingF) { action = "TRAILING_STOP_BREACH"; ratio = 70; reason = "TRAILING_STOP_PRICE_BREACH"; price = Math.Round(trailingF); priceSource = "TRAILING_STOP_PRICE"; priceBasis = "TRAILING_STOP_TRIGGER"; executionWindow = "INTRADAY_ON_TRIGGER"; orderType = "PROTECTIVE_LIMIT_SELL"; } else if ((!double.IsNaN(rwF) && rwF >= 2) || (!double.IsNaN(rwF) && rwF >= 1 && !double.IsNaN(timingExitF) && timingExitF >= 50)) { action = "TRIM_50"; ratio = 50; reason = (!double.IsNaN(rwF) && rwF >= 2) ? "RW_REVIEW" : "TIMING_EXIT_REVIEW"; price = closeProtectLimit; priceSource = "RELATIVE_WEAKNESS_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(rwF) && rwF >= 1 && !double.IsNaN(timingExitF) && timingExitF >= 30) { action = "TRIM_33"; ratio = 33; reason = "RW_EARLY_WARNING"; price = closeProtectLimit; priceSource = "EARLY_WARNING_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(rwF) && rwF >= 1) { action = "TRIM_25"; ratio = 25; reason = "RW_SIGNAL_ONLY"; price = closeProtectLimit; priceSource = "SIGNAL_ONLY_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(profitF) && profitF >= 50) { action = "PROFIT_TRIM_50"; ratio = 50; reason = "PROFIT_PROTECT_50"; price = (!double.IsNaN(tp2F) && tp2F > 0) ? (double?)Math.Round(tp2F) : closeProtectLimit; priceSource = !double.IsNaN(tp2F) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = !double.IsNaN(tp2F) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(profitF) && profitF >= 30) { action = "PROFIT_TRIM_35"; ratio = 35; reason = "PROFIT_PROTECT_30"; price = (!double.IsNaN(tp2F) && tp2F > 0) ? (double?)Math.Round(tp2F) : closeProtectLimit; priceSource = !double.IsNaN(tp2F) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = !double.IsNaN(tp2F) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(profitF) && profitF >= 20) { action = "PROFIT_TRIM_25"; ratio = 25; reason = "PROFIT_PROTECT_20"; price = (!double.IsNaN(tp1F) && tp1F > 0) ? (double?)Math.Round(tp1F) : closeProtectLimit; priceSource = !double.IsNaN(tp1F) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = !double.IsNaN(tp1F) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(profitF) && profitF >= 10) { action = "TAKE_PROFIT_TIER1"; ratio = 25; reason = "TP1_PROFIT_10PCT"; price = (!double.IsNaN(tp1F) && tp1F > 0) ? (double?)Math.Round(tp1F) : closeProtectLimit; priceSource = !double.IsNaN(tp1F) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = !double.IsNaN(tp1F) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(daysF) && daysF <= 0) { action = "TIME_EXIT_100"; ratio = 100; reason = "TIME_STOP_EXPIRED"; price = protectiveLimit; priceSource = "TIME_STOP_CLOSE"; priceBasis = "TIME_STOP_CLOSE_PROTECT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "PROTECTIVE_LIMIT_SELL"; } else if (!double.IsNaN(daysF) && daysF <= 7) { action = "TIME_TRIM_50"; ratio = 50; reason = "TIME_STOP_NEAR"; price = closeProtectLimit; priceSource = "TIME_STOP_NEAR_CLOSE"; priceBasis = "ATR_PROTECT_LIMIT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; } else if (!double.IsNaN(daysF) && daysF <= 14) { action = "TIME_TRIM_25"; ratio = 25; reason = "TIME_STOP_APPROACHING"; price = closeProtectLimit; priceSource = "TIME_STOP_APPROACHING_CLOSE"; priceBasis = "ATR_PROTECT_LIMIT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; } string validation = "NO_SELL_ACTION"; if (action != "HOLD") { validation = (price.HasValue && price.Value > 0) ? "SIGNAL_CONFIRMED" : "NO_SELL_PRICE"; } return new SellDecisionResult { Action = action, RatioPct = ratio, LimitPrice = price, PriceSource = priceSource, PriceBasis = priceBasis, ExecutionWindow = executionWindow, OrderType = orderType, Reason = reason, Validation = validation }; } public static FinalDecisionResult ComputeFinalDecision(Dictionary ctx) { string sellAction = GetString(ctx, "sellAction"); if (string.IsNullOrEmpty(sellAction)) sellAction = "HOLD"; string sellValidation = GetString(ctx, "sellValidation"); string allowedAction = GetString(ctx, "allowedAction"); string timingAction = GetString(ctx, "timingAction"); double? timingEntry = GetNullableDouble(ctx, "timingScoreEntry"); double? timingExit = GetNullableDouble(ctx, "timingScoreExit"); double? ss001Total = GetNullableDouble(ctx, "ss001Total"); double? flowCredit = GetNullableDouble(ctx, "flowCredit"); double? leaderTotal = GetNullableDouble(ctx, "leaderTotal"); double? rwPartial = GetNullableDouble(ctx, "rwPartial"); double? profitPct = GetNullableDouble(ctx, "profitPct"); double? daysToTimeStop = GetNullableDouble(ctx, "daysToTimeStop"); double? weightPct = GetNullableDouble(ctx, "weightPct"); string acGate = GetString(ctx, "acGate"); string liquidityStatus = GetString(ctx, "liquidityStatus"); string spreadStatus = GetString(ctx, "spreadStatus"); bool dartRisk = GetBool(ctx, "dartRisk"); string missingFields = GetString(ctx, "missingFields"); string finalAction = "HOLD"; int actionPriority = 99; string decisionSource = "RULE_ENGINE"; if (sellAction != "HOLD" && sellValidation == "SIGNAL_CONFIRMED") { finalAction = "SELL_READY"; actionPriority = 10; } else if (allowedAction == "EXIT_SIGNAL" || timingAction == "STOP_OR_TIME_EXIT_READY") { finalAction = "EXIT_SIGNAL"; actionPriority = 28; } else if (allowedAction == "REVIEW_EXIT" || timingAction == "EXIT_REVIEW") { finalAction = "EXIT_REVIEW"; actionPriority = 32; } else if (timingAction == "NO_BUY_OVERHEATED" && !dartRisk) { finalAction = "NO_BUY_OVERHEATED"; actionPriority = 50; } else if (allowedAction == "BUY_STAGE1_READY" || timingAction == "BUY_STAGE1_READY") { finalAction = "BUY_STAGE1_READY"; actionPriority = 60; } else if (allowedAction == "BUY_BREAKOUT_PILOT_ONLY" || timingAction == "BUY_BREAKOUT_PILOT_ONLY") { finalAction = "BUY_BREAKOUT_PILOT_ONLY"; actionPriority = 70; } else if (allowedAction == "BUY_PULLBACK_WAIT" || timingAction == "BUY_PULLBACK_WAIT") { finalAction = "BUY_PULLBACK_WAIT"; actionPriority = 80; } else if (allowedAction == "WATCH_CANDIDATE") { finalAction = "WATCH_TIMING_SETUP"; actionPriority = 90; } if (!string.IsNullOrEmpty(missingFields)) { decisionSource = "RULE_ENGINE_WITH_MISSING_DATA"; } double timeStopUrgency = (daysToTimeStop.HasValue && !double.IsNaN(daysToTimeStop.Value) && daysToTimeStop.Value >= 0) ? Math.Max(0.0, 20.0 - Math.Min(20.0, daysToTimeStop.Value * 3.0)) : 0.0; double overweightPenalty = (weightPct.HasValue && !double.IsNaN(weightPct.Value) && weightPct.Value > 7) ? 15.0 : 0.0; double overheatPenalty = acGate == "BLOCK" ? 30.0 : (acGate == "CAUTION" ? 10.0 : 0.0); double liquidityPenalty = (liquidityStatus == "LOW" || liquidityStatus == "DATA_MISSING" || spreadStatus == "BLOCK" || spreadStatus == "WIDE" || spreadStatus == "QUOTE_NO_MATCH") ? 15.0 : 0.0; double priorityScore = 0.0; if (actionPriority <= 40) { double exitVal = timingExit ?? 0.0; double rwVal = rwPartial ?? 0.0; double profitVal = profitPct ?? 0.0; priorityScore = exitVal * 0.35 + rwVal * 15.0 + Math.Max(0.0, profitVal) * 0.30 + timeStopUrgency + overweightPenalty; } else if (actionPriority >= 50 && actionPriority <= 80) { double entryVal = timingEntry ?? 0.0; double ssVal = ss001Total ?? 0.0; double flowVal = flowCredit ?? 0.0; double leaderVal = leaderTotal ?? 0.0; priorityScore = entryVal * 0.35 + ssVal * 0.30 + flowVal * 20.0 + leaderVal * 5.0 - overheatPenalty - liquidityPenalty; } else { double entryVal = timingEntry ?? 0.0; double exitVal = timingExit ?? 0.0; double flowVal = flowCredit ?? 0.0; priorityScore = entryVal * 0.20 + exitVal * 0.20 + flowVal * 10.0; } return new FinalDecisionResult { FinalAction = finalAction, ActionPriority = actionPriority, PriorityScore = Math.Max(0.0, priorityScore), DecisionSource = decisionSource }; } public static CashShortfallResult ComputeCashShortfallHarness( Dictionary asResult, double totalAsset, Dictionary cashFloorInfo, double mrsScore) { double asset = (double.IsFinite(totalAsset) && totalAsset > 0) ? totalAsset : 0.0; double d2Krw = 0.0; if (asResult.TryGetValue("settlementCashD2Krw", out var d2Val) && double.TryParse(d2Val?.ToString(), out var d2d)) { d2Krw = d2d; } double minPct = 0.0; if (cashFloorInfo.TryGetValue("minPct", out var minVal) && double.TryParse(minVal?.ToString(), out var minPctD)) { minPct = minPctD; } double targetCashPct = Math.Max(5.0 + (mrsScore / 10.0) * 15.0, minPct); return new CashShortfallResult { CashCurrentPctD2 = asset > 0 ? Math.Round((d2Krw / asset * 100.0), 2) : 0.0, CashTargetPct = targetCashPct, CashShortfallMinKrw = Math.Max(0.0, Math.Round(asset * minPct / 100.0 - d2Krw)), CashShortfallTargetKrw = Math.Max(0.0, Math.Round(asset * targetCashPct / 100.0 - d2Krw)) }; } public static CashRecoveryPlanResult ComputeCashRecoveryOptimizer( List> sellCandidates, double cashShortfallMinKrw) { var plan = new List(); double cumulativeKrw = 0.0; foreach (var cand in sellCandidates) { if (cumulativeKrw >= cashShortfallMinKrw) { break; } string ticker = GetString(cand, "Ticker") ?? GetString(cand, "ticker") ?? ""; string name = GetString(cand, "Name") ?? GetString(cand, "name") ?? ""; int qty = 0; if (cand.TryGetValue("Sell_Qty", out var qtyVal) && int.TryParse(qtyVal?.ToString(), out var qtyI)) { qty = qtyI; } double limitPrice = 0.0; if (cand.TryGetValue("Sell_Limit_Price", out var lpVal) && double.TryParse(lpVal?.ToString(), out var lpD)) { limitPrice = lpD; } else if (cand.TryGetValue("current_price", out var cpVal) && double.TryParse(cpVal?.ToString(), out var cpD)) { limitPrice = cpD; } double preserveRatio = 100.0; if (cand.TryGetValue("Cash_Preserve_Ratio", out var prVal) && double.TryParse(prVal?.ToString(), out var prD)) { preserveRatio = prD; } string style = GetString(cand, "Cash_Preserve_Style") ?? "FULL"; double expectedKrw = 0.0; if (qty > 0 && limitPrice > 0) { expectedKrw = qty * limitPrice * (preserveRatio / 100.0); } plan.Add(new CashRecoveryPlanItem { Ticker = ticker, Name = name, Qty = qty, LimitPrice = limitPrice > 0 ? (double?)KrxTickNormalizer.NormalizeTick(limitPrice) : null, PreserveStyle = style, PreserveRatio = preserveRatio, ExpectedKrw = Math.Round(expectedKrw) }); cumulativeKrw += expectedKrw; } bool shortfallMet = cumulativeKrw >= cashShortfallMinKrw; return new CashRecoveryPlanResult { SellSequence = plan, ExpectedTotalKrw = Math.Round(cumulativeKrw), CashShortfallMinKrw = cashShortfallMinKrw, ShortfallMet = shortfallMet, ItemsNeeded = plan.Count }; } // Helpers private static string GetString(Dictionary dict, string key) { if (dict.TryGetValue(key, out var val) && val != null) { return val.ToString() ?? ""; } return ""; } private static double? GetNullableDouble(Dictionary dict, string key) { if (dict.TryGetValue(key, out var val) && val != null) { if (double.TryParse(val.ToString(), out var d)) { return d; } } return null; } private static bool GetBool(Dictionary dict, string key) { if (dict.TryGetValue(key, out var val) && val != null) { if (bool.TryParse(val.ToString(), out var b)) { return b; } if (double.TryParse(val.ToString(), out var d)) { return d != 0; } } return false; } } }