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(() => 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); }