Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace KArtSell.ArchitectureTests;
|
||||
|
||||
public sealed class RepositoryRulesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prohibited_source_patterns_are_not_introduced()
|
||||
{
|
||||
var repositoryRoot = FindRepositoryRoot();
|
||||
var sourceFiles = Directory.EnumerateFiles(repositoryRoot, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(x => !IsGeneratedOrTestOutput(x))
|
||||
.ToArray();
|
||||
|
||||
AssertNoPattern(sourceFiles, "IGenericRepository", "Generic repository is prohibited.");
|
||||
AssertNoPattern(sourceFiles, "DateTime.Now", "Use IClock and MarketCalendar.");
|
||||
AssertNoPattern(sourceFiles, "DateTime.UtcNow", "Use IClock and MarketCalendar.");
|
||||
AssertNoPattern(sourceFiles, "IServiceProvider.GetService", "Service locator is prohibited.");
|
||||
AssertNoPattern(sourceFiles, "AllowAnonymous()", "Module endpoints cannot be anonymous.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Domain_files_do_not_reference_infrastructure_frameworks()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var domainFiles = Directory.EnumerateFiles(
|
||||
Path.Combine(root, "src"),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories)
|
||||
.Where(path => path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Domain{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
foreach (var prohibited in new[]
|
||||
{
|
||||
"using Dapper", "using Npgsql", "using FastEndpoints", "using Hangfire",
|
||||
"HttpContext", "DbConnection", "IServiceCollection"
|
||||
})
|
||||
{
|
||||
AssertNoPattern(domainFiles, prohibited,
|
||||
$"Domain cannot depend on infrastructure framework: {prohibited}.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sql_does_not_use_select_star_or_unqualified_signal_tables()
|
||||
{
|
||||
var repositoryRoot = FindRepositoryRoot();
|
||||
var files = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
|
||||
.Where(x => x.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
|
||||
|| x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(x => !IsGeneratedOrTestOutput(x))
|
||||
.ToArray();
|
||||
|
||||
var selectStar = files.Where(x =>
|
||||
File.ReadAllText(x).Contains("select *", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
Assert.True(selectStar.Length == 0,
|
||||
"SELECT * is prohibited: " + string.Join(", ", selectStar));
|
||||
|
||||
var unqualified = files.Where(x =>
|
||||
{
|
||||
var text = File.ReadAllText(x);
|
||||
return text.Contains(" from signal_decision", StringComparison.OrdinalIgnoreCase)
|
||||
|| text.Contains(" into signal_decision", StringComparison.OrdinalIgnoreCase)
|
||||
|| text.Contains(" from evidence_snapshot", StringComparison.OrdinalIgnoreCase);
|
||||
}).ToArray();
|
||||
Assert.True(unqualified.Length == 0,
|
||||
"SignalEngine SQL must be schema-qualified: " + string.Join(", ", unqualified));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_module_endpoint_declares_roles_or_policies()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var endpoints = Directory.EnumerateFiles(
|
||||
Path.Combine(root, "src"),
|
||||
"Endpoint.cs",
|
||||
SearchOption.AllDirectories)
|
||||
.Where(path => path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Modules{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
var violations = endpoints.Where(path =>
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
return !text.Contains("Roles(", StringComparison.Ordinal)
|
||||
&& !text.Contains("Policies(", StringComparison.Ordinal);
|
||||
}).ToArray();
|
||||
|
||||
Assert.True(violations.Length == 0,
|
||||
"Every module endpoint must declare Roles or Policies: " + string.Join(", ", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accidental_placeholder_files_are_not_committed()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var names = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
||||
.Where(path => !IsGeneratedOrTestOutput(path))
|
||||
.Where(path => Path.GetFileName(path).Equals("testfile", StringComparison.OrdinalIgnoreCase)
|
||||
|| Path.GetFileName(path).EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
Assert.True(names.Length == 0,
|
||||
"Placeholder files are prohibited: " + string.Join(", ", names));
|
||||
}
|
||||
|
||||
private static void AssertNoPattern(IEnumerable<string> files, string pattern, string message)
|
||||
{
|
||||
var violations = files
|
||||
.Where(path => File.ReadAllText(path).Contains(pattern, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.True(violations.Length == 0, message + " " + string.Join(", ", violations));
|
||||
}
|
||||
|
||||
private static bool IsGeneratedOrTestOutput(string path)
|
||||
=> path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
|| path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
|| path.Contains($"{Path.DirectorySeparatorChar}tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
|| path.Contains($"{Path.DirectorySeparatorChar}attachments{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
|| path.Contains($"{Path.DirectorySeparatorChar}research{Path.DirectorySeparatorChar}original{Path.DirectorySeparatorChar}", StringComparison.Ordinal);
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
public sealed class EvaluationReconciliationPlannerTests
|
||||
{
|
||||
[Fact] public void Missing_windows_are_planned_and_duplicates_quarantined() {
|
||||
var plan=EvaluationReconciliationPlanner.Plan([new EvaluationWindowFact(1,true,true,false,false,false)]);
|
||||
Assert.Equal(EvaluationReconciliationAction.QuarantineDuplicate,plan[1]);
|
||||
Assert.Equal(EvaluationReconciliationAction.PlanMissing,plan[252]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
public sealed class EvaluationWindowPlannerTests
|
||||
{
|
||||
[Fact] public void Uses_trading_sessions_not_calendar_days()
|
||||
{
|
||||
var planner = new EvaluationWindowPlanner();
|
||||
var due = planner.Plan("KRX", new DateOnly(2026, 8, 3), new WeekdayCalendar());
|
||||
Assert.Equal(new[] { 1, 5, 20, 63, 126, 252 }, due.Select(x => x.WindowTradingDays));
|
||||
Assert.All(due, x => Assert.DoesNotContain(x.DueSession.DayOfWeek, new[] { DayOfWeek.Saturday, DayOfWeek.Sunday }));
|
||||
}
|
||||
private sealed class WeekdayCalendar : ITradingSessionCalendar
|
||||
{
|
||||
public DateOnly AddTradingSessions(string calendarId, DateOnly startSession, int sessionCount)
|
||||
{
|
||||
var current = startSession; var count = 0;
|
||||
while (count < sessionCount) { current = current.AddDays(1); if (current.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++; }
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/KArtSell.Modules.ModelOperations/KArtSell.Modules.ModelOperations.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,41 @@
|
||||
using KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public sealed class ModelFeedbackCycleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Happy_path_stops_at_human_promotion_review_and_closes()
|
||||
{
|
||||
var cycle = new ModelFeedbackCycle(Guid.NewGuid(), "KR-EQUITY", "model-1", DateTimeOffset.UtcNow);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
cycle.MoveTo(ModelFeedbackCycleState.PredictionFrozen, "FREEZE_OK", "hash-1", "system", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.OutcomesMaturing, "WINDOWS_OPEN", "hash-2", "system", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.Evaluated, "SCORECARD_READY", "hash-3", "system", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.ImprovementProposed, "HYPOTHESIS_REVIEWED", "hash-4", "quant", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.ChallengerPlanned, "PLAN_APPROVED", "hash-5", "validator", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.IndependentlyValidated, "OOS_VALIDATED", "hash-6", "validator", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.PromotionReviewPending, "PACK_READY", "hash-7", "risk", now);
|
||||
cycle.MoveTo(ModelFeedbackCycleState.Closed, "HUMAN_DECISION_RECORDED", "hash-8", "investment-committee", now);
|
||||
|
||||
Assert.Equal(ModelFeedbackCycleState.Closed, cycle.State);
|
||||
Assert.Equal(9, cycle.Revision);
|
||||
Assert.Equal(8, cycle.Transitions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skipping_independent_validation_is_rejected()
|
||||
{
|
||||
var cycle = new ModelFeedbackCycle(Guid.NewGuid(), "US-EQUITY", "model-1", DateTimeOffset.UtcNow);
|
||||
Assert.Throws<InvalidOperationException>(() => cycle.MoveTo(
|
||||
ModelFeedbackCycleState.PromotionReviewPending, "SKIP", "hash", "system", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_contains_manual_only_activation_boundary()
|
||||
{
|
||||
var activation = Assert.Single(ModelFeedbackPlan.Stages.Where(x => x.StageCode == "ACTIVATE"));
|
||||
Assert.Equal("AUTOMATION_FORBIDDEN", activation.AutomationBoundary);
|
||||
Assert.Contains("NO_AUTOMATIC_MODEL_ACTIVATION", ModelFeedbackPlan.Boundary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public sealed class ModelImprovementHypothesisTests
|
||||
{
|
||||
[Fact]
|
||||
public void Decision_required_and_missing_counter_evidence_block_experiment()
|
||||
{
|
||||
var hypothesis = new ModelImprovementHypothesis(
|
||||
Guid.NewGuid(), "KR-EQUITY", "model-1", "false exit increased", "review floor calibration",
|
||||
"reject when OOS utility does not improve under double cost",
|
||||
[new(EvidenceClassification.DecisionRequired, "DEC-012", "latest PIT source unresolved", "h1")],
|
||||
[], "Quant Lead", DateTimeOffset.UtcNow.AddDays(30));
|
||||
|
||||
var errors = hypothesis.Validate(DateTimeOffset.UtcNow);
|
||||
Assert.Contains("COUNTER_EVIDENCE_REQUIRED", errors);
|
||||
Assert.Contains("DECISION_REQUIRED_BLOCKS_EXPERIMENT", errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public sealed class ModelOperationExecutionBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_definitions_are_unique_and_evidence_only()
|
||||
{
|
||||
Assert.Equal(ModelOperationRegistry.All.Count, ModelOperationRegistry.All.Select(x => x.OperationCode).Distinct().Count());
|
||||
Assert.All(ModelOperationRegistry.All, ModelOperationExecutionBoundary.EnsureAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_never_contains_order_or_auto_promotion_operations()
|
||||
{
|
||||
var forbidden = new[] { "ORDER", "BROKER", "AUTO_PROMOTE", "AUTO_ROLLBACK", "KIS_SUBMIT" };
|
||||
Assert.All(ModelOperationRegistry.All, operation =>
|
||||
Assert.DoesNotContain(forbidden, token => operation.Name.Contains(token, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
public sealed class ModelOperationExecutionTests
|
||||
{
|
||||
[Fact] public void Business_hold_can_resume_but_success_is_terminal()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var execution = new ModelOperationExecution(Guid.NewGuid(), "J32:KR:1", now);
|
||||
execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_NOT_READY", "h1", now);
|
||||
execution.MoveTo(ModelOperationExecutionState.Running, "DATA_READY", "h2", now.AddMinutes(1));
|
||||
execution.MoveTo(ModelOperationExecutionState.Succeeded, "OUTPUT_FROZEN", "h3", now.AddMinutes(2));
|
||||
Assert.Throws<InvalidOperationException>(() => execution.MoveTo(ModelOperationExecutionState.Running, "REOPEN", "h4", now.AddMinutes(3)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
public sealed class ModelOperationLeaseTests
|
||||
{
|
||||
[Fact] public void Stale_token_cannot_renew() {
|
||||
var now=DateTimeOffset.UtcNow; var lease=new ModelOperationLease("J42","GLOBAL",new LeaseFencingToken(3),"worker-a",now.AddMinutes(5));
|
||||
Assert.Throws<InvalidOperationException>(()=>lease.Renew(new LeaseFencingToken(2),"worker-a",now.AddMinutes(10)));
|
||||
}
|
||||
[Fact] public void Expired_lease_transfer_increments_token() {
|
||||
var now=DateTimeOffset.UtcNow; var lease=new ModelOperationLease("J42","GLOBAL",new LeaseFencingToken(3),"worker-a",now.AddMinutes(-1));
|
||||
Assert.Equal(4,lease.Transfer(now,"worker-b",now.AddMinutes(5)).Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public sealed class ModelOperationRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Operation_codes_are_unique_and_no_auto_promotion_mode_exists()
|
||||
{
|
||||
var codes = ModelOperationRegistry.All.Select(x => x.OperationCode).ToArray();
|
||||
|
||||
Assert.Equal(codes.Length, codes.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
Assert.All(ModelOperationRegistry.All, operation =>
|
||||
Assert.Contains(operation.AutomationMode, new[]
|
||||
{
|
||||
AutomationMode.EvaluationOnly,
|
||||
AutomationMode.ProposalOnly,
|
||||
AutomationMode.DrillOnly
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Improvement_and_promotion_packet_jobs_are_proposal_only()
|
||||
{
|
||||
Assert.Equal(AutomationMode.ProposalOnly, ModelOperationRegistry.GetRequired("J21").AutomationMode);
|
||||
Assert.Equal(AutomationMode.ProposalOnly, ModelOperationRegistry.GetRequired("J22").AutomationMode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public sealed class PromotionGateEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Passes_evidence_gate_but_still_requires_human_approval()
|
||||
{
|
||||
var snapshot = new ModelEvaluationSnapshot(
|
||||
"GLOBAL",
|
||||
"model-1",
|
||||
504,
|
||||
3,
|
||||
0.08m,
|
||||
0.01m,
|
||||
0.70m,
|
||||
0.10m,
|
||||
0.97m,
|
||||
true,
|
||||
0,
|
||||
true,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var result = new PromotionGateEvaluator().Evaluate(snapshot, PromotionGateThresholds.ResearchBaseline);
|
||||
|
||||
Assert.Equal(GateDecision.Pass, result.Decision);
|
||||
Assert.Empty(result.BlockingReasons);
|
||||
Assert.True(result.RequiresIndependentValidation);
|
||||
Assert.True(result.RequiresHumanApproval);
|
||||
Assert.Equal("EVIDENCE_ONLY_NO_AUTO_PROMOTION", result.Boundary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Holds_when_any_operational_integrity_error_exists()
|
||||
{
|
||||
var snapshot = new ModelEvaluationSnapshot(
|
||||
"GLOBAL",
|
||||
"model-1",
|
||||
504,
|
||||
3,
|
||||
0.08m,
|
||||
0.01m,
|
||||
0.70m,
|
||||
0.10m,
|
||||
0.97m,
|
||||
true,
|
||||
1,
|
||||
true,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var result = new PromotionGateEvaluator().Evaluate(snapshot, PromotionGateThresholds.ResearchBaseline);
|
||||
|
||||
Assert.Equal(GateDecision.Hold, result.Decision);
|
||||
Assert.Contains(result.BlockingReasons, reason => reason.Contains("Operational integrity", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
public sealed class ScheduleOccurrencePlannerTests
|
||||
{
|
||||
private readonly ScheduleOccurrencePlanner planner = new();
|
||||
[Fact] public void Daily_anchor_does_not_drift_to_dispatch_time()
|
||||
{
|
||||
var scheduled = new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero);
|
||||
var next = planner.GetNextDueAt(scheduled, "DAILY", "LATEST_ONLY", 1, scheduled.AddHours(10));
|
||||
Assert.Equal(scheduled.AddDays(1), next);
|
||||
}
|
||||
[Fact] public void Missed_occurrences_are_skipped_without_dispatch_storm()
|
||||
{
|
||||
var scheduled = new DateTimeOffset(2026, 7, 1, 1, 0, 0, TimeSpan.Zero);
|
||||
var next = planner.GetNextDueAt(scheduled, "DAILY", "LATEST_ONLY", 1, new DateTimeOffset(2026, 8, 1, 4, 0, 0, TimeSpan.Zero));
|
||||
Assert.True(next > new DateTimeOffset(2026, 8, 1, 4, 0, 0, TimeSpan.Zero));
|
||||
Assert.Equal(1, next.Hour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using KArtSell.BuildingBlocks.DataIntegrity;
|
||||
|
||||
namespace KArtSell.SignalEngine.UnitTests.BuildingBlocks;
|
||||
|
||||
public sealed class PitRecordMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void Quarantined_record_is_never_usable()
|
||||
{
|
||||
var published = new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
|
||||
var record = new PitRecordMetadata(
|
||||
"source-1", published, published, published.AddMinutes(1), published, null,
|
||||
0, "hash", "dataset", DataQualityStatus.Quarantined);
|
||||
|
||||
Assert.False(record.IsUsableAt(published.AddDays(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pass_record_is_usable_only_after_publication()
|
||||
{
|
||||
var published = new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
|
||||
var record = new PitRecordMetadata(
|
||||
"source-1", published, published, published.AddMinutes(1), published, null,
|
||||
0, "hash", "dataset", DataQualityStatus.Pass);
|
||||
|
||||
Assert.False(record.IsUsableAt(published.AddTicks(-1)));
|
||||
Assert.True(record.IsUsableAt(published));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using KArtSell.BuildingBlocks.Versioning;
|
||||
|
||||
namespace KArtSell.SignalEngine.UnitTests.BuildingBlocks;
|
||||
|
||||
public sealed class VersionSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Blank_version_component_is_rejected()
|
||||
{
|
||||
var value = new VersionSet("dataset", "data", "model", "config", "", "contract");
|
||||
Assert.Throws<ArgumentException>(value.EnsureValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
using KArtSell.Modules.SignalEngine.Domain;
|
||||
|
||||
namespace KArtSell.SignalEngine.UnitTests;
|
||||
|
||||
public sealed class ReentryStateMachineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reentry_requires_wait_spacing_trend_breakout_and_asset_confirmation()
|
||||
{
|
||||
var state = ReentryStateMachine.Evaluate(
|
||||
ReentryState.Watching,
|
||||
new ReentryInput(10, 10, true, true, true, false, false));
|
||||
|
||||
Assert.Equal(ReentryState.Ready, state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_asset_confirmation_keeps_watch_open()
|
||||
{
|
||||
var state = ReentryStateMachine.Evaluate(
|
||||
ReentryState.Watching,
|
||||
new ReentryInput(10, 10, true, true, false, false, false));
|
||||
|
||||
Assert.Equal(ReentryState.Watching, state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Executed_stage_moves_to_reentered_then_watching_when_stages_remain()
|
||||
{
|
||||
var reentered = ReentryStateMachine.Evaluate(
|
||||
ReentryState.Ready,
|
||||
new ReentryInput(10, 10, true, true, true, false, false,
|
||||
StageExecuted: true, HasRemainingStages: true));
|
||||
|
||||
var next = ReentryStateMachine.Evaluate(
|
||||
reentered,
|
||||
new ReentryInput(10, 0, true, true, true, false, false,
|
||||
HasRemainingStages: true));
|
||||
|
||||
Assert.Equal(ReentryState.Reentered, reentered);
|
||||
Assert.Equal(ReentryState.Watching, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Last_executed_stage_moves_to_open()
|
||||
{
|
||||
var next = ReentryStateMachine.Evaluate(
|
||||
ReentryState.Reentered,
|
||||
new ReentryInput(20, 10, true, true, true, false, false,
|
||||
HasRemainingStages: false));
|
||||
|
||||
Assert.Equal(ReentryState.Open, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hard_impairment_closes_watch()
|
||||
{
|
||||
var state = ReentryStateMachine.Evaluate(
|
||||
ReentryState.Watching,
|
||||
new ReentryInput(100, 100, true, true, true, true, false));
|
||||
|
||||
Assert.Equal(ReentryState.Closed, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using KArtSell.Modules.SignalEngine.Domain;
|
||||
namespace KArtSell.SignalEngine.UnitTests;
|
||||
public sealed class SellDecisionEvidenceGuardTests
|
||||
{
|
||||
[Fact] public void Rejects_lookahead_and_unit_confusion() {
|
||||
var now=DateTimeOffset.UtcNow;
|
||||
Assert.Throws<InvalidOperationException>(()=>SellDecisionEvidenceGuard.EnsureValid(new("e","d","m","c","sha",now,now.AddMinutes(1),.2m,.3m,.1m,.2m)));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(()=>SellDecisionEvidenceGuard.EnsureValid(new("e","d","m","c","sha",now,now,20m,.3m,.1m,.2m)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using KArtSell.Modules.SignalEngine.Domain;
|
||||
using KArtSell.Modules.SignalEngine.Domain.Policies;
|
||||
|
||||
namespace KArtSell.SignalEngine.UnitTests;
|
||||
|
||||
public sealed class SellPolicyChainTests
|
||||
{
|
||||
private static SellPolicyChain CreateSut() => new(new ISellPolicy[]
|
||||
{
|
||||
new OpportunityCostPolicy(),
|
||||
new ConcentrationLiquidityPolicy(),
|
||||
new TwoCloseFloorBreachPolicy(),
|
||||
new GapFloorBreachPolicy(),
|
||||
new PortfolioSurvivalPolicy(),
|
||||
new HardImpairmentPolicy()
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void Hard_impairment_cannot_be_overridden_by_lower_priority_policy()
|
||||
{
|
||||
var input = BaseInput() with
|
||||
{
|
||||
HardImpairmentApproved = true,
|
||||
CapitalFloorBreached = true,
|
||||
GapBelowFloorAtr = 2.0m,
|
||||
ConsecutiveCloseBreaches = 3,
|
||||
OpportunityEdgeLowerBound = 0.10m,
|
||||
OpportunitySellRatioOfLot = 0.25m
|
||||
};
|
||||
|
||||
var result = CreateSut().Evaluate(input);
|
||||
|
||||
Assert.Equal("ALG-SELL-001", result.PolicyId);
|
||||
Assert.Equal(SellAction.FullSell, result.Action);
|
||||
Assert.Equal(1m, result.SellRatioOfLot);
|
||||
Assert.False(result.ReentryEligible);
|
||||
Assert.Single(result.PolicyTrace);
|
||||
Assert.Equal(PolicyDisposition.Applied, result.PolicyTrace[0].Disposition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_survival_outranks_profit_floor_and_may_cross_strategic_core()
|
||||
{
|
||||
var input = BaseInput() with
|
||||
{
|
||||
CurrentSecurityPortfolioWeight = 0.40m,
|
||||
CurrentLotPortfolioWeight = 0.20m,
|
||||
StrategicCoreFloorWeight = 0.30m,
|
||||
CapitalFloorBreached = true,
|
||||
SurvivalSellRatioOfLot = 0.75m,
|
||||
GapBelowFloorAtr = 2.0m
|
||||
};
|
||||
|
||||
var result = CreateSut().Evaluate(input);
|
||||
|
||||
Assert.Equal("ALG-SELL-PORT-001", result.PolicyId);
|
||||
Assert.Equal(0.75m, result.SellRatioOfLot);
|
||||
Assert.Equal(0.25m, result.TargetSecurityPortfolioWeightAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lot_relative_ratio_uses_lot_weight_not_whole_security_weight()
|
||||
{
|
||||
var input = BaseInput() with
|
||||
{
|
||||
CurrentSecurityPortfolioWeight = 0.60m,
|
||||
CurrentLotPortfolioWeight = 0.20m,
|
||||
StrategicCoreFloorWeight = 0.50m,
|
||||
GapBelowFloorAtr = 2.0m
|
||||
};
|
||||
|
||||
var result = CreateSut().Evaluate(input);
|
||||
|
||||
Assert.Equal("ALG-SELL-002", result.PolicyId);
|
||||
Assert.Equal(0.40m, result.SellRatioOfLot);
|
||||
Assert.Equal(0.52m, result.TargetSecurityPortfolioWeightAfter);
|
||||
Assert.False(result.PolicyTrace.Single(x => x.PolicyId == "ALG-SELL-002").StrategicCoreClampApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Strategic_core_clamps_lot_ratio_when_only_part_of_lot_is_sellable()
|
||||
{
|
||||
var input = BaseInput() with
|
||||
{
|
||||
CurrentSecurityPortfolioWeight = 0.60m,
|
||||
CurrentLotPortfolioWeight = 0.40m,
|
||||
StrategicCoreFloorWeight = 0.50m,
|
||||
GapBelowFloorAtr = 2.0m
|
||||
};
|
||||
|
||||
var result = CreateSut().Evaluate(input);
|
||||
|
||||
Assert.Equal(0.25m, result.SellRatioOfLot);
|
||||
Assert.Equal(0.50m, result.TargetSecurityPortfolioWeightAfter);
|
||||
Assert.True(result.PolicyTrace.Single(x => x.PolicyId == "ALG-SELL-002").StrategicCoreClampApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opportunity_sell_requires_positive_lower_confidence_edge()
|
||||
{
|
||||
var blocked = CreateSut().Evaluate(BaseInput() with
|
||||
{
|
||||
OpportunityEdgeLowerBound = 0m,
|
||||
OpportunitySellRatioOfLot = 0.20m
|
||||
});
|
||||
|
||||
var allowed = CreateSut().Evaluate(BaseInput() with
|
||||
{
|
||||
OpportunityEdgeLowerBound = 0.01m,
|
||||
OpportunitySellRatioOfLot = 0.20m
|
||||
});
|
||||
|
||||
Assert.Equal(SellAction.Hold, blocked.Action);
|
||||
Assert.Equal("ALG-SELL-005", allowed.PolicyId);
|
||||
Assert.Equal(0.20m, allowed.SellRatioOfLot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Positive_opportunity_edge_with_zero_requested_ratio_cannot_create_a_sell()
|
||||
{
|
||||
var result = CreateSut().Evaluate(BaseInput() with
|
||||
{
|
||||
OpportunityEdgeLowerBound = 0.02m,
|
||||
OpportunitySellRatioOfLot = 0m
|
||||
});
|
||||
|
||||
Assert.Equal(SellAction.Hold, result.Action);
|
||||
var trace = Assert.Single(result.PolicyTrace, x => x.PolicyId == SellPolicyContract.OpportunityCostPolicyId);
|
||||
Assert.Equal(PolicyDisposition.Blocked, trace.Disposition);
|
||||
Assert.Equal("OPPORTUNITY_RATIO_NOT_POSITIVE", trace.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Future_published_evidence_is_rejected()
|
||||
{
|
||||
var input = BaseInput() with
|
||||
{
|
||||
PublishedAtCutoff = DateTimeOffset.Parse("2026-08-01T08:00:00Z"),
|
||||
AsOf = DateTimeOffset.Parse("2026-08-01T07:00:00Z")
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => CreateSut().Evaluate(input));
|
||||
}
|
||||
|
||||
private static SellDecisionInput BaseInput() => new(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000002"),
|
||||
"evidence-1",
|
||||
"dataset-1",
|
||||
"model-1",
|
||||
"config-1",
|
||||
"code-sha-1",
|
||||
DateTimeOffset.Parse("2026-08-01T07:00:00Z"),
|
||||
DateTimeOffset.Parse("2026-08-01T06:00:00Z"),
|
||||
0.60m,
|
||||
0.20m,
|
||||
0.30m,
|
||||
false,
|
||||
false,
|
||||
0m,
|
||||
0m,
|
||||
0,
|
||||
true,
|
||||
0m,
|
||||
0m,
|
||||
0m);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using KArtSell.Modules.SignalEngine.Domain;
|
||||
|
||||
namespace KArtSell.SignalEngine.UnitTests;
|
||||
|
||||
public sealed class SellPolicyContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void Policy_ids_and_priorities_are_unique_and_strictly_ordered()
|
||||
{
|
||||
var definitions = SellPolicyContract.Definitions;
|
||||
|
||||
Assert.Equal(definitions.Count, definitions.Select(x => x.PolicyId).Distinct().Count());
|
||||
Assert.Equal(definitions.Count, definitions.Select(x => x.Priority).Distinct().Count());
|
||||
Assert.True(definitions.Zip(definitions.Skip(1), (left, right) => left.Priority > right.Priority).All(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approved_ratios_and_thresholds_remain_within_documented_ranges()
|
||||
{
|
||||
Assert.InRange(SellPolicyContract.HardImpairmentSellRatioOfLot, 0.80m, 1.00m);
|
||||
Assert.InRange(SellPolicyContract.GapFloorSellRatioOfLot, 0.30m, 0.50m);
|
||||
Assert.InRange(SellPolicyContract.TwoCloseSellRatioOfLot, 0.15m, 0.25m);
|
||||
Assert.InRange(SellPolicyContract.OpportunityMinimumSellRatioOfLot, 0.10m, 0.25m);
|
||||
Assert.InRange(SellPolicyContract.OpportunityMaximumSellRatioOfLot, 0.10m, 0.25m);
|
||||
Assert.True(SellPolicyContract.OpportunityMinimumSellRatioOfLot <= SellPolicyContract.OpportunityMaximumSellRatioOfLot);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user