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

This commit is contained in:
2026-06-26 11:25:32 +09:00
parent c1e84a387c
commit 10e1cfe409
11 changed files with 922 additions and 21 deletions
@@ -1,6 +0,0 @@
namespace QuantEngine.Application;
public class Class1
{
}
@@ -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<IEnumerable<WorkspaceApproval>> GetApprovalsAsync() => _repository.GetApprovalsAsync();
public Task<WorkspaceApproval?> GetApprovalAsync(string domain, string targetRef) => _repository.GetApprovalAsync(domain, targetRef);
public Task<bool> UpsertApprovalAsync(WorkspaceApproval approval) => _repository.UpsertApprovalAsync(approval);
public Task<IEnumerable<WorkspaceLock>> GetLocksAsync() => _repository.GetLocksAsync();
public Task<WorkspaceLock?> GetLockAsync(string domain, string targetRef) => _repository.GetLockAsync(domain, targetRef);
public Task<bool> AcquireLockAsync(WorkspaceLock @lock) => _repository.AcquireLockAsync(@lock);
public Task<bool> ReleaseLockAsync(string domain, string targetRef) => _repository.ReleaseLockAsync(domain, targetRef);
}
}
@@ -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<IEnumerable<Setting>> GetSettingsAsync() => _repository.GetSettingsAsync();
public Task<Setting?> GetSettingByKeyAsync(string key) => _repository.GetSettingByKeyAsync(key);
public Task<bool> UpsertSettingAsync(Setting setting) => _repository.UpsertSettingAsync(setting);
public Task<bool> DeleteSettingAsync(string key) => _repository.DeleteSettingAsync(key);
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync();
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots);
public Task<bool> ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync();
}
}
@@ -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<string, object>
{
{ "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);
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -18,4 +18,8 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
</ItemGroup>
</Project>
-6
View File
@@ -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;
}
}
}
@@ -1,6 +0,0 @@
namespace QuantEngine.Infrastructure;
public class Class1
{
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
@@ -8,7 +8,6 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Npgsql" Version="10.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.9" />
</ItemGroup>
<PropertyGroup>
+14
View File
@@ -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