diff --git a/src/dotnet/QuantEngine.Application/Class1.cs b/src/dotnet/QuantEngine.Application/Class1.cs deleted file mode 100644 index bfbd238..0000000 --- a/src/dotnet/QuantEngine.Application/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace QuantEngine.Application; - -public class Class1 -{ - -} diff --git a/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs b/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs new file mode 100644 index 0000000..fff0f38 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; + +namespace QuantEngine.Application.Services +{ + public class ApprovalService + { + private readonly IWorkspaceRepository _repository; + + public ApprovalService(IWorkspaceRepository repository) + { + _repository = repository; + } + + public Task> GetApprovalsAsync() => _repository.GetApprovalsAsync(); + public Task GetApprovalAsync(string domain, string targetRef) => _repository.GetApprovalAsync(domain, targetRef); + public Task UpsertApprovalAsync(WorkspaceApproval approval) => _repository.UpsertApprovalAsync(approval); + + public Task> GetLocksAsync() => _repository.GetLocksAsync(); + public Task GetLockAsync(string domain, string targetRef) => _repository.GetLockAsync(domain, targetRef); + public Task AcquireLockAsync(WorkspaceLock @lock) => _repository.AcquireLockAsync(@lock); + public Task ReleaseLockAsync(string domain, string targetRef) => _repository.ReleaseLockAsync(domain, targetRef); + } +} diff --git a/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs new file mode 100644 index 0000000..effa7aa --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; + +namespace QuantEngine.Application.Services +{ + public class WorkspaceService + { + private readonly IWorkspaceRepository _repository; + + public WorkspaceService(IWorkspaceRepository repository) + { + _repository = repository; + } + + public Task> GetSettingsAsync() => _repository.GetSettingsAsync(); + public Task GetSettingByKeyAsync(string key) => _repository.GetSettingByKeyAsync(key); + public Task UpsertSettingAsync(Setting setting) => _repository.UpsertSettingAsync(setting); + public Task DeleteSettingAsync(string key) => _repository.DeleteSettingAsync(key); + + public Task> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync(); + public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots); + public Task ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync(); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs new file mode 100644 index 0000000..63140c6 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using QuantEngine.Core.Domain; + +namespace QuantEngine.Core.Tests; + +public class FormulaEngineTests +{ + [Fact] + public void TestTimingDecisionNeutral() + { + var ctx = new Dictionary + { + { "entryModeGate", "PASS" }, + { "entryMode", "BREAKOUT" }, + { "leaderGate", "PASS" }, + { "acGate", "CLEAR" }, + { "leaderTotal", 4.0 }, + { "flowCredit", 0.8 }, + { "ma20Slope", 1.0 }, + { "disparity", 0.0 }, + { "rsi14", 50.0 }, + { "avgTradeValue5D", 100.0 }, + { "spreadPct", 0.1 }, + { "priceStatus", "PRICE_OK" }, + { "atr20", 10.0 } + }; + + var result = FormulaEngine.ComputeTimingDecision(ctx); + Assert.NotNull(result); + Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj index db296aa..acbcfa6 100644 --- a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj +++ b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -18,4 +18,8 @@ + + + + \ No newline at end of file diff --git a/src/dotnet/QuantEngine.Core/Class1.cs b/src/dotnet/QuantEngine.Core/Class1.cs deleted file mode 100644 index efa8020..0000000 --- a/src/dotnet/QuantEngine.Core/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace QuantEngine.Core; - -public class Class1 -{ - -} diff --git a/src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs b/src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs new file mode 100644 index 0000000..0a52563 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs @@ -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 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 + { + 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 (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 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; + } + } +} diff --git a/src/dotnet/QuantEngine.Core/Domain/HarnessInjector.cs b/src/dotnet/QuantEngine.Core/Domain/HarnessInjector.cs new file mode 100644 index 0000000..5cd3f87 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Domain/HarnessInjector.cs @@ -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 InjectComputedHarness( + Dictionary rawHarness, + IEnumerable snapshots, + IEnumerable settings) + { + var result = new Dictionary(rawHarness); + + // Sync total asset + double settingsTotal = 0; + foreach (var setting in settings) + { + if (setting.Key == "total_asset_krw") + { + try + { + var je = JsonSerializer.Deserialize(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; + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Class1.cs b/src/dotnet/QuantEngine.Infrastructure/Class1.cs deleted file mode 100644 index 6863688..0000000 --- a/src/dotnet/QuantEngine.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace QuantEngine.Infrastructure; - -public class Class1 -{ - -} diff --git a/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj b/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj index 9768f63..5bc2915 100644 --- a/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj +++ b/src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj @@ -1,4 +1,4 @@ - + @@ -8,7 +8,6 @@ - diff --git a/src/dotnet/QuantEngine.sln b/src/dotnet/QuantEngine.sln index b7a031f..deff15b 100644 --- a/src/dotnet/QuantEngine.sln +++ b/src/dotnet/QuantEngine.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Infrastructure" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web", "QuantEngine.Web\QuantEngine.Web.csproj", "{15F27C9D-077F-4788-9F6A-88092BEA1814}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "QuantEngine.Core.Tests\QuantEngine.Core.Tests.csproj", "{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {15F27C9D-077F-4788-9F6A-88092BEA1814}.Release|x64.Build.0 = Release|Any CPU {15F27C9D-077F-4788-9F6A-88092BEA1814}.Release|x86.ActiveCfg = Release|Any CPU {15F27C9D-077F-4788-9F6A-88092BEA1814}.Release|x86.Build.0 = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|x64.Build.0 = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Debug|x86.Build.0 = Debug|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|Any CPU.Build.0 = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x64.ActiveCfg = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x64.Build.0 = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.ActiveCfg = Release|Any CPU + {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE