feat(dotnet): 파이썬 공식 계산 엔진 C# 포팅 및 .NET 인프라 기반 결함(WBS-10.1) 해결
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m18s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m18s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
namespace QuantEngine.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
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<string> CashPreserveReason { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
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<CashRecoveryPlanItem> SellSequence { get; set; } = new List<CashRecoveryPlanItem>();
|
||||
public double ExpectedTotalKrw { get; set; }
|
||||
public double CashShortfallMinKrw { get; set; }
|
||||
public bool ShortfallMet { get; set; }
|
||||
public int ItemsNeeded { get; set; }
|
||||
}
|
||||
|
||||
public static class FormulaEngine
|
||||
{
|
||||
public static TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
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 (leaderTotal.HasValue && !double.IsNaN(leaderTotal.Value) && !double.IsInfinity(leaderTotal.Value))
|
||||
{
|
||||
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 (flowCredit.HasValue && !double.IsNaN(flowCredit.Value) && !double.IsInfinity(flowCredit.Value))
|
||||
{
|
||||
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 (ma20Slope.HasValue && !double.IsNaN(ma20Slope.Value) && !double.IsInfinity(ma20Slope.Value))
|
||||
{
|
||||
if (ma20Slope.Value > 0)
|
||||
{
|
||||
entryScore += 8;
|
||||
}
|
||||
else
|
||||
{
|
||||
entryScore -= 8;
|
||||
exitScore += 8;
|
||||
reasons.Add("ma20_down");
|
||||
}
|
||||
}
|
||||
|
||||
if (disparity.HasValue && !double.IsNaN(disparity.Value) && !double.IsInfinity(disparity.Value))
|
||||
{
|
||||
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 (rsi14.HasValue && !double.IsNaN(rsi14.Value) && !double.IsInfinity(rsi14.Value))
|
||||
{
|
||||
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 (avgTradeValue5D.HasValue && !double.IsNaN(avgTradeValue5D.Value) && !double.IsInfinity(avgTradeValue5D.Value) && avgTradeValue5D.Value >= 50 && (!spreadPct.HasValue || double.IsNaN(spreadPct.Value) || spreadPct.Value <= 0.8))
|
||||
{
|
||||
entryScore += 10;
|
||||
}
|
||||
else
|
||||
{
|
||||
entryScore -= 15;
|
||||
reasons.Add("liquidity_or_spread_fail");
|
||||
}
|
||||
|
||||
if (rwPartial.HasValue && !double.IsNaN(rwPartial.Value) && !double.IsInfinity(rwPartial.Value))
|
||||
{
|
||||
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 (daysToTimeStop.HasValue && !double.IsNaN(daysToTimeStop.Value) && daysToTimeStop.Value >= 0 && daysToTimeStop.Value <= 7)
|
||||
{
|
||||
exitScore += 20;
|
||||
reasons.Add("time_stop_near");
|
||||
}
|
||||
|
||||
if (profitPct.HasValue && !double.IsNaN(profitPct.Value) && 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" || !atr20.HasValue || double.IsNaN(atr20.Value) || double.IsInfinity(atr20.Value))
|
||||
{
|
||||
action = "OBSERVE_DATA_MISSING";
|
||||
}
|
||||
else if (exitScore >= 75 || (rwPartial.HasValue && rwPartial.Value >= 4))
|
||||
{
|
||||
action = "STOP_OR_TIME_EXIT_READY";
|
||||
}
|
||||
else if (exitScore >= 50 || (rwPartial.HasValue && 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<string, object> 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<string, object> 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<string, object> asResult,
|
||||
double totalAsset,
|
||||
Dictionary<string, object> 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<Dictionary<string, object>> sellCandidates,
|
||||
double cashShortfallMinKrw)
|
||||
{
|
||||
var plan = new List<CashRecoveryPlanItem>();
|
||||
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<string, object> dict, string key)
|
||||
{
|
||||
if (dict.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
return val.ToString() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static double? GetNullableDouble(Dictionary<string, object> 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<string, object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Domain;
|
||||
using QuantEngine.Core.Models;
|
||||
|
||||
namespace QuantEngine.Core.Domain
|
||||
{
|
||||
public static class HarnessInjector
|
||||
{
|
||||
public static Dictionary<string, object> InjectComputedHarness(
|
||||
Dictionary<string, object> rawHarness,
|
||||
IEnumerable<AccountSnapshot> snapshots,
|
||||
IEnumerable<Setting> settings)
|
||||
{
|
||||
var result = new Dictionary<string, object>(rawHarness);
|
||||
|
||||
// Sync total asset
|
||||
double settingsTotal = 0;
|
||||
foreach (var setting in settings)
|
||||
{
|
||||
if (setting.Key == "total_asset_krw")
|
||||
{
|
||||
try
|
||||
{
|
||||
var je = JsonSerializer.Deserialize<JsonElement>(setting.ValueJson);
|
||||
if (je.ValueKind == JsonValueKind.Number && je.TryGetDouble(out var td))
|
||||
{
|
||||
settingsTotal = td;
|
||||
}
|
||||
else if (je.ValueKind == JsonValueKind.String && double.TryParse(je.GetString(), out var ts))
|
||||
{
|
||||
settingsTotal = ts;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsTotal > 0)
|
||||
{
|
||||
result["total_asset_krw"] = settingsTotal;
|
||||
result["total_asset"] = settingsTotal;
|
||||
}
|
||||
|
||||
// Freshness and intraday
|
||||
result["data_freshness_status"] = "FRESH";
|
||||
result["intraday_scope"] = "INTRADAY_ACTIVE";
|
||||
|
||||
// Aggregate metrics and populate
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user