Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -0,0 +1,35 @@
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Application;
public sealed record GenerateSellDecisionCommand(
Guid PositionLotId,
DateTimeOffset AsOf,
string IdempotencyKey,
string CorrelationId);
public sealed record GeneratedSellDecision(
Guid DecisionId,
string Action,
decimal SellRatioOfLot,
decimal TargetSecurityPortfolioWeightAfter,
string PolicyId,
string ReasonCode,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
string DecisionContractVersion,
int PolicyTraceSchemaVersion,
bool ReentryEligible,
IReadOnlyList<PolicyTraceEntry> PolicyTrace,
DateTimeOffset CreatedAt,
bool Replayed);
public interface ISellDecisionService
{
Task<GeneratedSellDecision?> GenerateAsync(
GenerateSellDecisionCommand command,
CancellationToken cancellationToken);
}
@@ -0,0 +1,62 @@
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Application;
public sealed record SellDecisionContext(
Guid ContextId,
Guid PositionLotId,
Guid CycleId,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
DateTimeOffset AsOf,
DateTimeOffset PublishedAtCutoff,
decimal CurrentSecurityPortfolioWeight,
decimal CurrentLotPortfolioWeight,
decimal StrategicCoreFloorWeight,
bool HardImpairmentApproved,
bool CapitalFloorBreached,
decimal SurvivalSellRatioOfLot,
decimal GapBelowFloorAtr,
int ConsecutiveCloseBreaches,
bool CooldownSatisfied,
decimal ConcentrationSellRatioOfLot,
decimal OpportunityEdgeLowerBound,
decimal OpportunitySellRatioOfLot,
string QualityStatus,
string ContentHash)
{
public SellDecisionInput ToDomainInput()
=> new(
PositionLotId,
CycleId,
EvidenceId,
DatasetId,
ModelVersion,
ConfigVersion,
CodeSha,
AsOf,
PublishedAtCutoff,
CurrentSecurityPortfolioWeight,
CurrentLotPortfolioWeight,
StrategicCoreFloorWeight,
HardImpairmentApproved,
CapitalFloorBreached,
SurvivalSellRatioOfLot,
GapBelowFloorAtr,
ConsecutiveCloseBreaches,
CooldownSatisfied,
ConcentrationSellRatioOfLot,
OpportunityEdgeLowerBound,
OpportunitySellRatioOfLot);
}
public interface ISellDecisionContextReader
{
Task<SellDecisionContext?> GetApprovedAsync(
Guid positionLotId,
DateTimeOffset asOf,
CancellationToken cancellationToken);
}
@@ -0,0 +1,292 @@
using System.Data;
using System.Text.Json;
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Hashing;
using KArtSell.BuildingBlocks.Reliability;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Application;
public sealed class SellDecisionService(
IDbConnectionFactory connectionFactory,
ISellDecisionContextReader contextReader,
SellPolicyChain policyChain,
IOutboxWriter outboxWriter,
IClock clock) : ISellDecisionService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private const string SelectExistingSql = """
select
decision_id as DecisionId,
action as Action,
sell_ratio_of_lot as SellRatioOfLot,
target_security_portfolio_weight_after as TargetSecurityPortfolioWeightAfter,
policy_id as PolicyId,
reason_code as ReasonCode,
evidence_id as EvidenceId,
dataset_id as DatasetId,
model_version as ModelVersion,
config_version as ConfigVersion,
code_sha as CodeSha,
decision_contract_version as DecisionContractVersion,
policy_trace_schema_version as PolicyTraceSchemaVersion,
reentry_eligible as ReentryEligible,
policy_trace_json::text as PolicyTraceJson,
created_at as CreatedAt
from signal_engine.signal_decision
where idempotency_key = @IdempotencyKey;
""";
private const string InsertSql = """
insert into signal_engine.signal_decision
(
decision_id,
position_lot_id,
cycle_id,
action,
sell_ratio_of_lot,
target_security_portfolio_weight_after,
policy_id,
priority,
reason_code,
evidence_id,
dataset_id,
model_version,
config_version,
code_sha,
decision_contract_version,
policy_trace_schema_version,
reentry_eligible,
policy_trace_json,
idempotency_key,
correlation_id,
created_at,
decision_hash
)
values
(
@DecisionId,
@PositionLotId,
@CycleId,
@Action,
@SellRatioOfLot,
@TargetSecurityPortfolioWeightAfter,
@PolicyId,
@Priority,
@ReasonCode,
@EvidenceId,
@DatasetId,
@ModelVersion,
@ConfigVersion,
@CodeSha,
@DecisionContractVersion,
@PolicyTraceSchemaVersion,
@ReentryEligible,
cast(@PolicyTraceJson as jsonb),
@IdempotencyKey,
@CorrelationId,
@CreatedAt,
@DecisionHash
)
on conflict (idempotency_key) do nothing;
""";
public async Task<GeneratedSellDecision?> GenerateAsync(
GenerateSellDecisionCommand command,
CancellationToken cancellationToken)
{
var context = await contextReader.GetApprovedAsync(
command.PositionLotId,
command.AsOf,
cancellationToken);
if (context is null)
{
return null;
}
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var existing = await connection.QuerySingleOrDefaultAsync<PersistedDecision>(
new CommandDefinition(
SelectExistingSql,
new { command.IdempotencyKey },
transaction,
cancellationToken: cancellationToken));
if (existing is not null)
{
await transaction.CommitAsync(cancellationToken);
return existing.ToResult(replayed: true);
}
var decision = policyChain.Evaluate(context.ToDomainInput());
var now = clock.UtcNow;
var decisionId = Guid.NewGuid();
var policyTraceJson = JsonSerializer.Serialize(decision.PolicyTrace, JsonOptions);
var decisionCanonicalJson = JsonSerializer.Serialize(new
{
DecisionId = decisionId,
context.PositionLotId,
context.CycleId,
decision.Action,
decision.SellRatioOfLot,
decision.TargetSecurityPortfolioWeightAfter,
decision.PolicyId,
decision.Priority,
decision.ReasonCode,
decision.EvidenceId,
decision.DatasetId,
decision.ModelVersion,
decision.ConfigVersion,
decision.CodeSha,
DecisionContractVersion = SellPolicyContract.DecisionContractVersion,
PolicyTraceSchemaVersion = SellPolicyContract.PolicyTraceSchemaVersion,
decision.ReentryEligible,
PolicyTrace = decision.PolicyTrace,
CreatedAt = now
}, JsonOptions);
var decisionHash = ContentHasher.Sha256(decisionCanonicalJson);
var insert = new
{
DecisionId = decisionId,
context.PositionLotId,
context.CycleId,
Action = decision.Action.ToString().ToUpperInvariant(),
decision.SellRatioOfLot,
decision.TargetSecurityPortfolioWeightAfter,
decision.PolicyId,
decision.Priority,
decision.ReasonCode,
decision.EvidenceId,
decision.DatasetId,
decision.ModelVersion,
decision.ConfigVersion,
decision.CodeSha,
DecisionContractVersion = SellPolicyContract.DecisionContractVersion,
PolicyTraceSchemaVersion = SellPolicyContract.PolicyTraceSchemaVersion,
decision.ReentryEligible,
PolicyTraceJson = policyTraceJson,
command.IdempotencyKey,
command.CorrelationId,
CreatedAt = now,
DecisionHash = decisionHash
};
var affected = await connection.ExecuteAsync(
new CommandDefinition(
InsertSql,
insert,
transaction,
cancellationToken: cancellationToken));
if (affected == 0)
{
var replay = await connection.QuerySingleAsync<PersistedDecision>(
new CommandDefinition(
SelectExistingSql,
new { command.IdempotencyKey },
transaction,
cancellationToken: cancellationToken));
await transaction.CommitAsync(cancellationToken);
return replay.ToResult(replayed: true);
}
var payload = JsonSerializer.Serialize(new
{
DecisionId = decisionId,
context.PositionLotId,
decision.PolicyId,
Action = decision.Action.ToString(),
decision.SellRatioOfLot,
decision.TargetSecurityPortfolioWeightAfter,
decision.EvidenceId,
decision.DatasetId,
DecisionContractVersion = SellPolicyContract.DecisionContractVersion,
PolicyTraceSchemaVersion = SellPolicyContract.PolicyTraceSchemaVersion,
CreatedAt = now
}, JsonOptions);
await outboxWriter.AddAsync(
connection,
transaction,
new OutboxMessage(
Guid.NewGuid(),
"SignalDecisionCreated",
2,
payload,
command.CorrelationId,
now,
ContentHasher.Sha256(payload)),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GeneratedSellDecision(
decisionId,
decision.Action.ToString(),
decision.SellRatioOfLot,
decision.TargetSecurityPortfolioWeightAfter,
decision.PolicyId,
decision.ReasonCode,
decision.EvidenceId,
decision.DatasetId,
decision.ModelVersion,
decision.ConfigVersion,
decision.CodeSha,
SellPolicyContract.DecisionContractVersion,
SellPolicyContract.PolicyTraceSchemaVersion,
decision.ReentryEligible,
decision.PolicyTrace,
now,
false);
}
private sealed record PersistedDecision(
Guid DecisionId,
string Action,
decimal SellRatioOfLot,
decimal TargetSecurityPortfolioWeightAfter,
string PolicyId,
string ReasonCode,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
string DecisionContractVersion,
int PolicyTraceSchemaVersion,
bool ReentryEligible,
string PolicyTraceJson,
DateTimeOffset CreatedAt)
{
public GeneratedSellDecision ToResult(bool replayed)
=> new(
DecisionId,
Action,
SellRatioOfLot,
TargetSecurityPortfolioWeightAfter,
PolicyId,
ReasonCode,
EvidenceId,
DatasetId,
ModelVersion,
ConfigVersion,
CodeSha,
DecisionContractVersion,
PolicyTraceSchemaVersion,
ReentryEligible,
JsonSerializer.Deserialize<List<PolicyTraceEntry>>(PolicyTraceJson, JsonOptions)
?? new List<PolicyTraceEntry>(),
CreatedAt,
replayed);
}
}
@@ -0,0 +1,8 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public interface ISellPolicy
{
int Priority { get; }
string PolicyId { get; }
SellPolicyResult Evaluate(SellDecisionInput input);
}
@@ -0,0 +1,44 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
public sealed class ConcentrationLiquidityPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.ConcentrationLiquidityPriority;
public string PolicyId => SellPolicyContract.ConcentrationLiquidityPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
var requested = Math.Clamp(input.ConcentrationSellRatioOfLot, 0m, 1m);
if (requested <= 0m)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "NO_CONCENTRATION_EXCESS");
}
if (!input.CooldownSatisfied)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "COOLDOWN_NOT_SATISFIED", requested);
}
var max = input.MaxSellRatioPreservingStrategicCore();
var applied = Math.Min(requested, max);
if (applied <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "STRATEGIC_CORE_BLOCKED", requested);
}
var decision = new SellDecision(
SellAction.PartialSell,
applied,
input.TargetSecurityPortfolioWeightAfter(applied),
PolicyId,
Priority,
"CONCENTRATION_OR_LIQUIDITY",
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
true,
Array.Empty<PolicyTraceEntry>());
return SellPolicyResult.Applied(decision, requested, applied < requested);
}
}
@@ -0,0 +1,47 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
public sealed class GapFloorBreachPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.GapFloorBreachPriority;
public string PolicyId => SellPolicyContract.GapFloorBreachPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
const decimal requested = SellPolicyContract.GapFloorSellRatioOfLot;
if (input.GapBelowFloorAtr < SellPolicyContract.GapFloorAtrThreshold)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "GAP_BELOW_1_5_ATR");
}
if (!input.CooldownSatisfied)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "COOLDOWN_NOT_SATISFIED", requested);
}
var max = input.MaxSellRatioPreservingStrategicCore();
var applied = Math.Min(requested, max);
if (applied <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "STRATEGIC_CORE_BLOCKED", requested);
}
var decision = Create(input, applied, "GAP_FLOOR_BREACH");
return SellPolicyResult.Applied(decision, requested, applied < requested);
}
private SellDecision Create(SellDecisionInput input, decimal ratio, string reason)
=> new(
SellAction.PartialSell,
ratio,
input.TargetSecurityPortfolioWeightAfter(ratio),
PolicyId,
Priority,
reason,
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
true,
Array.Empty<PolicyTraceEntry>());
}
@@ -0,0 +1,40 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
public sealed class HardImpairmentPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.HardImpairmentPriority;
public string PolicyId => SellPolicyContract.HardImpairmentPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
if (!input.HardImpairmentApproved)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "HARD_IMPAIRMENT_NOT_APPROVED");
}
const decimal ratio = SellPolicyContract.HardImpairmentSellRatioOfLot;
var decision = Create(input, ratio, SellAction.FullSell, "HARD_IMPAIRMENT", false);
return SellPolicyResult.Applied(decision, ratio, false);
}
private SellDecision Create(
SellDecisionInput input,
decimal ratio,
SellAction action,
string reason,
bool reentryEligible)
=> new(
action,
ratio,
input.TargetSecurityPortfolioWeightAfter(ratio),
PolicyId,
Priority,
reason,
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
reentryEligible,
Array.Empty<PolicyTraceEntry>());
}
@@ -0,0 +1,53 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
public sealed class OpportunityCostPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.OpportunityCostPriority;
public string PolicyId => SellPolicyContract.OpportunityCostPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
if (input.OpportunityEdgeLowerBound <= 0m)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "EDGE_LOWER_BOUND_NOT_POSITIVE");
}
if (input.OpportunitySellRatioOfLot <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "OPPORTUNITY_RATIO_NOT_POSITIVE");
}
var requested = Math.Clamp(
input.OpportunitySellRatioOfLot,
SellPolicyContract.OpportunityMinimumSellRatioOfLot,
SellPolicyContract.OpportunityMaximumSellRatioOfLot);
if (!input.CooldownSatisfied)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "COOLDOWN_NOT_SATISFIED", requested);
}
var max = input.MaxSellRatioPreservingStrategicCore();
var applied = Math.Min(requested, max);
if (applied <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "STRATEGIC_CORE_BLOCKED", requested);
}
var decision = new SellDecision(
SellAction.PartialSell,
applied,
input.TargetSecurityPortfolioWeightAfter(applied),
PolicyId,
Priority,
"OPPORTUNITY_REPLACEMENT",
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
true,
Array.Empty<PolicyTraceEntry>());
return SellPolicyResult.Applied(decision, requested, applied < requested);
}
}
@@ -0,0 +1,44 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
/// <summary>Capital-floor protection may cross the strategic core because survival outranks profit protection.</summary>
public sealed class PortfolioSurvivalPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.PortfolioSurvivalPriority;
public string PolicyId => SellPolicyContract.PortfolioSurvivalPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
if (!input.CapitalFloorBreached)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "CAPITAL_FLOOR_NOT_BREACHED");
}
var requested = Math.Clamp(input.SurvivalSellRatioOfLot, 0m, 1m);
if (!input.CooldownSatisfied)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "COOLDOWN_NOT_SATISFIED", requested);
}
if (requested <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "SURVIVAL_RATIO_NOT_POSITIVE", requested);
}
var action = requested >= 1m ? SellAction.FullSell : SellAction.PartialSell;
var decision = new SellDecision(
action,
requested,
input.TargetSecurityPortfolioWeightAfter(requested),
PolicyId,
Priority,
"PORTFOLIO_SURVIVAL",
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
true,
Array.Empty<PolicyTraceEntry>());
return SellPolicyResult.Applied(decision, requested, false);
}
}
@@ -0,0 +1,44 @@
namespace KArtSell.Modules.SignalEngine.Domain.Policies;
public sealed class TwoCloseFloorBreachPolicy : ISellPolicy
{
public int Priority => SellPolicyContract.TwoCloseFloorBreachPriority;
public string PolicyId => SellPolicyContract.TwoCloseFloorBreachPolicyId;
public SellPolicyResult Evaluate(SellDecisionInput input)
{
const decimal requested = SellPolicyContract.TwoCloseSellRatioOfLot;
if (input.ConsecutiveCloseBreaches < SellPolicyContract.TwoCloseBreachCount)
{
return SellPolicyResult.NotApplicable(PolicyId, Priority, "TWO_CLOSE_NOT_CONFIRMED");
}
if (!input.CooldownSatisfied)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "COOLDOWN_NOT_SATISFIED", requested);
}
var max = input.MaxSellRatioPreservingStrategicCore();
var applied = Math.Min(requested, max);
if (applied <= 0m)
{
return SellPolicyResult.Blocked(PolicyId, Priority, "STRATEGIC_CORE_BLOCKED", requested);
}
var decision = new SellDecision(
SellAction.PartialSell,
applied,
input.TargetSecurityPortfolioWeightAfter(applied),
PolicyId,
Priority,
"TWO_CLOSE_FLOOR_BREACH",
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
true,
Array.Empty<PolicyTraceEntry>());
return SellPolicyResult.Applied(decision, requested, applied < requested);
}
}
@@ -0,0 +1,61 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public enum ReentryState
{
Watching,
Ready,
Reentered,
Open,
Expired,
Closed
}
public sealed record ReentryInput(
int SessionsSinceSell,
int SessionsSinceLastStage,
bool RisingSma50Reclaimed,
bool Prior20SessionHighBroken,
bool AdditionalAssetConfirmationPassed,
bool HardImpairment,
bool Expired,
bool StageExecuted = false,
bool HasRemainingStages = false);
public static class ReentryStateMachine
{
public static ReentryState Evaluate(ReentryState current, ReentryInput input)
{
if (input.HardImpairment)
{
return ReentryState.Closed;
}
if (input.Expired && current is not ReentryState.Open)
{
return ReentryState.Expired;
}
if (current is ReentryState.Expired or ReentryState.Closed or ReentryState.Open)
{
return current;
}
if (current is ReentryState.Ready && input.StageExecuted)
{
return ReentryState.Reentered;
}
if (current is ReentryState.Reentered)
{
return input.HasRemainingStages ? ReentryState.Watching : ReentryState.Open;
}
var ready = input.SessionsSinceSell >= 10
&& input.SessionsSinceLastStage >= 10
&& input.RisingSma50Reclaimed
&& input.Prior20SessionHighBroken
&& input.AdditionalAssetConfirmationPassed;
return ready ? ReentryState.Ready : ReentryState.Watching;
}
}
@@ -0,0 +1,40 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public enum SellAction
{
Hold = 0,
PartialSell = 1,
FullSell = 2
}
public sealed record SellDecision(
SellAction Action,
decimal SellRatioOfLot,
decimal TargetSecurityPortfolioWeightAfter,
string PolicyId,
int Priority,
string ReasonCode,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
bool ReentryEligible,
IReadOnlyList<PolicyTraceEntry> PolicyTrace)
{
public static SellDecision Hold(SellDecisionInput input, IReadOnlyList<PolicyTraceEntry> trace)
=> new(
SellAction.Hold,
0m,
input.CurrentSecurityPortfolioWeight,
"ALG-HOLD-001",
0,
"NO_SELL_CONDITION",
input.EvidenceId,
input.DatasetId,
input.ModelVersion,
input.ConfigVersion,
input.CodeSha,
false,
trace);
}
@@ -0,0 +1,20 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public sealed record SellDecisionEvidence(
string EvidenceId, string DatasetId, string ModelVersion, string ConfigVersion, string CodeSha,
DateTimeOffset AsOf, DateTimeOffset PublishedAtCutoff, decimal SellRatioOfLot,
decimal CurrentPortfolioWeight, decimal StrategicCoreFloorWeight, decimal TargetPortfolioWeightAfter);
public static class SellDecisionEvidenceGuard
{
public static void EnsureValid(SellDecisionEvidence evidence)
{
if (string.IsNullOrWhiteSpace(evidence.EvidenceId) || string.IsNullOrWhiteSpace(evidence.DatasetId) || string.IsNullOrWhiteSpace(evidence.ModelVersion) || string.IsNullOrWhiteSpace(evidence.ConfigVersion) || string.IsNullOrWhiteSpace(evidence.CodeSha))
throw new InvalidOperationException("Evidence and VersionSet fields are required.");
if (evidence.PublishedAtCutoff > evidence.AsOf) throw new InvalidOperationException("Look-ahead evidence is forbidden.");
EnsureUnitInterval(evidence.SellRatioOfLot,nameof(evidence.SellRatioOfLot));
EnsureUnitInterval(evidence.CurrentPortfolioWeight,nameof(evidence.CurrentPortfolioWeight));
EnsureUnitInterval(evidence.StrategicCoreFloorWeight,nameof(evidence.StrategicCoreFloorWeight));
EnsureUnitInterval(evidence.TargetPortfolioWeightAfter,nameof(evidence.TargetPortfolioWeightAfter));
if (evidence.TargetPortfolioWeightAfter > evidence.CurrentPortfolioWeight) throw new InvalidOperationException("Sell decision cannot increase portfolio weight.");
}
private static void EnsureUnitInterval(decimal value,string name) { if (value<0m || value>1m) throw new ArgumentOutOfRangeException(name,value,"Expected 0..1."); }
}
@@ -0,0 +1,72 @@
namespace KArtSell.Modules.SignalEngine.Domain;
/// <summary>
/// Immutable point-in-time input resolved server-side from an approved EvidenceSnapshot/read model.
/// Security weight and lot weight are intentionally separate: a lot-relative sell ratio must not be
/// multiplied by the whole security weight.
/// </summary>
public sealed record SellDecisionInput(
Guid PositionLotId,
Guid CycleId,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
DateTimeOffset AsOf,
DateTimeOffset PublishedAtCutoff,
decimal CurrentSecurityPortfolioWeight,
decimal CurrentLotPortfolioWeight,
decimal StrategicCoreFloorWeight,
bool HardImpairmentApproved,
bool CapitalFloorBreached,
decimal SurvivalSellRatioOfLot,
decimal GapBelowFloorAtr,
int ConsecutiveCloseBreaches,
bool CooldownSatisfied,
decimal ConcentrationSellRatioOfLot,
decimal OpportunityEdgeLowerBound,
decimal OpportunitySellRatioOfLot)
{
public void EnsureValid()
{
if (PublishedAtCutoff > AsOf)
{
throw new InvalidOperationException("PublishedAtCutoff cannot be later than AsOf.");
}
if (CurrentSecurityPortfolioWeight is < 0m or > 1m)
{
throw new InvalidOperationException("CurrentSecurityPortfolioWeight must be between 0 and 1.");
}
if (CurrentLotPortfolioWeight is < 0m or > 1m
|| CurrentLotPortfolioWeight > CurrentSecurityPortfolioWeight)
{
throw new InvalidOperationException("CurrentLotPortfolioWeight must be between 0 and the security weight.");
}
if (StrategicCoreFloorWeight is < 0m or > 1m)
{
throw new InvalidOperationException("StrategicCoreFloorWeight must be between 0 and 1.");
}
}
public decimal MaxSellRatioPreservingStrategicCore()
{
if (CurrentLotPortfolioWeight <= 0m)
{
return 0m;
}
var sellableSecurityWeight = Math.Max(0m, CurrentSecurityPortfolioWeight - StrategicCoreFloorWeight);
return Math.Clamp(sellableSecurityWeight / CurrentLotPortfolioWeight, 0m, 1m);
}
public decimal TargetSecurityPortfolioWeightAfter(decimal sellRatioOfLot)
=> decimal.Round(
Math.Max(0m, CurrentSecurityPortfolioWeight
- CurrentLotPortfolioWeight * Math.Clamp(sellRatioOfLot, 0m, 1m)),
8,
MidpointRounding.ToEven);
}
@@ -0,0 +1,27 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public sealed class SellPolicyChain(IEnumerable<ISellPolicy> policies)
{
private readonly ISellPolicy[] _policies = policies
.OrderByDescending(x => x.Priority)
.ThenBy(x => x.PolicyId, StringComparer.Ordinal)
.ToArray();
public SellDecision Evaluate(SellDecisionInput input)
{
input.EnsureValid();
var trace = new List<PolicyTraceEntry>(_policies.Length);
foreach (var policy in _policies)
{
var result = policy.Evaluate(input);
trace.Add(result.Trace);
if (result.Decision is not null)
{
return result.Decision with { PolicyTrace = trace.AsReadOnly() };
}
}
return SellDecision.Hold(input, trace.AsReadOnly());
}
}
@@ -0,0 +1,47 @@
namespace KArtSell.Modules.SignalEngine.Domain;
/// <summary>
/// Approved implementation contract for the v12.3 research-candidate sell policy chain.
/// Values are mirrored in contracts/policies/sell-policy-contract.v1.json and checked by validate_v123.py.
/// Changing any value requires a Model Change record, Golden vectors and OOS impact evidence.
/// </summary>
public static class SellPolicyContract
{
public const string ContractVersion = "sell-policy.v1";
public const string DecisionContractVersion = "sell-decision.v2";
public const int PolicyTraceSchemaVersion = 2;
public const string HardImpairmentPolicyId = "ALG-SELL-001";
public const string PortfolioSurvivalPolicyId = "ALG-SELL-PORT-001";
public const string GapFloorBreachPolicyId = "ALG-SELL-002";
public const string TwoCloseFloorBreachPolicyId = "ALG-SELL-003";
public const string ConcentrationLiquidityPolicyId = "ALG-SELL-004";
public const string OpportunityCostPolicyId = "ALG-SELL-005";
public const int HardImpairmentPriority = 1000;
public const int PortfolioSurvivalPriority = 900;
public const int GapFloorBreachPriority = 800;
public const int TwoCloseFloorBreachPriority = 700;
public const int ConcentrationLiquidityPriority = 600;
public const int OpportunityCostPriority = 500;
public const decimal HardImpairmentSellRatioOfLot = 1.00m;
public const decimal GapFloorAtrThreshold = 1.50m;
public const decimal GapFloorSellRatioOfLot = 0.40m;
public const int TwoCloseBreachCount = 2;
public const decimal TwoCloseSellRatioOfLot = 0.20m;
public const decimal OpportunityMinimumSellRatioOfLot = 0.10m;
public const decimal OpportunityMaximumSellRatioOfLot = 0.25m;
public static IReadOnlyList<SellPolicyDefinition> Definitions { get; } =
[
new(HardImpairmentPolicyId, HardImpairmentPriority, true),
new(PortfolioSurvivalPolicyId, PortfolioSurvivalPriority, true),
new(GapFloorBreachPolicyId, GapFloorBreachPriority, false),
new(TwoCloseFloorBreachPolicyId, TwoCloseFloorBreachPriority, false),
new(ConcentrationLiquidityPolicyId, ConcentrationLiquidityPriority, false),
new(OpportunityCostPolicyId, OpportunityCostPriority, false)
];
}
public sealed record SellPolicyDefinition(string PolicyId, int Priority, bool MayCrossStrategicCore);
@@ -0,0 +1,52 @@
namespace KArtSell.Modules.SignalEngine.Domain;
public enum PolicyDisposition
{
NotApplicable = 0,
Blocked = 1,
Applied = 2
}
public sealed record PolicyTraceEntry(
string PolicyId,
int Priority,
PolicyDisposition Disposition,
string ReasonCode,
decimal RequestedSellRatioOfLot,
decimal AppliedSellRatioOfLot,
bool StrategicCoreClampApplied);
public sealed record SellPolicyResult(
PolicyTraceEntry Trace,
SellDecision? Decision)
{
public static SellPolicyResult NotApplicable(string policyId, int priority, string reasonCode)
=> new(new PolicyTraceEntry(policyId, priority, PolicyDisposition.NotApplicable, reasonCode, 0m, 0m, false), null);
public static SellPolicyResult Blocked(
string policyId,
int priority,
string reasonCode,
decimal requestedSellRatioOfLot = 0m)
=> new(new PolicyTraceEntry(
policyId,
priority,
PolicyDisposition.Blocked,
reasonCode,
requestedSellRatioOfLot,
0m,
false), null);
public static SellPolicyResult Applied(
SellDecision decision,
decimal requestedSellRatioOfLot,
bool strategicCoreClampApplied)
=> new(new PolicyTraceEntry(
decision.PolicyId,
decision.Priority,
PolicyDisposition.Applied,
decision.ReasonCode,
requestedSellRatioOfLot,
decision.SellRatioOfLot,
strategicCoreClampApplied), decision);
}
@@ -0,0 +1,52 @@
using FastEndpoints;
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Features.EvaluateResearchSellPolicy;
public sealed class Endpoint(SellPolicyChain policyChain) : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/internal/v1/research/sell-policy/evaluate");
Roles("Quant", "System");
Description(x => x.WithTags("ResearchOnly"));
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
var decision = policyChain.Evaluate(new SellDecisionInput(
req.PositionLotId,
req.CycleId,
req.EvidenceId,
req.DatasetId,
req.ModelVersion,
req.ConfigVersion,
req.CodeSha,
req.AsOf,
req.PublishedAtCutoff,
req.CurrentSecurityPortfolioWeight,
req.CurrentLotPortfolioWeight,
req.StrategicCoreFloorWeight,
req.HardImpairmentApproved,
req.CapitalFloorBreached,
req.SurvivalSellRatioOfLot,
req.GapBelowFloorAtr,
req.ConsecutiveCloseBreaches,
req.CooldownSatisfied,
req.ConcentrationSellRatioOfLot,
req.OpportunityEdgeLowerBound,
req.OpportunitySellRatioOfLot));
await Send.OkAsync(new Response(
decision.Action.ToString(),
decision.SellRatioOfLot,
decision.TargetSecurityPortfolioWeightAfter,
decision.PolicyId,
decision.ReasonCode,
SellPolicyContract.DecisionContractVersion,
SellPolicyContract.PolicyTraceSchemaVersion,
decision.ReentryEligible,
decision.PolicyTrace,
"RESEARCH_CANDIDATE_NOT_PRODUCTION"), ct);
}
}
@@ -0,0 +1,25 @@
namespace KArtSell.Modules.SignalEngine.Features.EvaluateResearchSellPolicy;
/// <summary>Research-only vector endpoint. It never persists, publishes, or submits an order.</summary>
public sealed record Request(
Guid PositionLotId,
Guid CycleId,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
DateTimeOffset AsOf,
DateTimeOffset PublishedAtCutoff,
decimal CurrentSecurityPortfolioWeight,
decimal CurrentLotPortfolioWeight,
decimal StrategicCoreFloorWeight,
bool HardImpairmentApproved,
bool CapitalFloorBreached,
decimal SurvivalSellRatioOfLot,
decimal GapBelowFloorAtr,
int ConsecutiveCloseBreaches,
bool CooldownSatisfied,
decimal ConcentrationSellRatioOfLot,
decimal OpportunityEdgeLowerBound,
decimal OpportunitySellRatioOfLot);
@@ -0,0 +1,15 @@
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Features.EvaluateResearchSellPolicy;
public sealed record Response(
string Action,
decimal SellRatioOfLot,
decimal TargetSecurityPortfolioWeightAfter,
string PolicyId,
string ReasonCode,
string DecisionContractVersion,
int PolicyTraceSchemaVersion,
bool ReentryEligible,
IReadOnlyList<PolicyTraceEntry> PolicyTrace,
string EvidenceStatus);
@@ -0,0 +1,28 @@
using FastEndpoints;
using FluentValidation;
namespace KArtSell.Modules.SignalEngine.Features.EvaluateResearchSellPolicy;
public sealed class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.PositionLotId).NotEmpty();
RuleFor(x => x.CycleId).NotEmpty();
RuleFor(x => x.EvidenceId).NotEmpty().MaximumLength(128);
RuleFor(x => x.DatasetId).NotEmpty().MaximumLength(128);
RuleFor(x => x.ModelVersion).NotEmpty().MaximumLength(128);
RuleFor(x => x.ConfigVersion).NotEmpty().MaximumLength(128);
RuleFor(x => x.CodeSha).NotEmpty().MaximumLength(128);
RuleFor(x => x.PublishedAtCutoff).LessThanOrEqualTo(x => x.AsOf);
RuleFor(x => x.CurrentSecurityPortfolioWeight).InclusiveBetween(0m, 1m);
RuleFor(x => x.CurrentLotPortfolioWeight).InclusiveBetween(0m, 1m)
.LessThanOrEqualTo(x => x.CurrentSecurityPortfolioWeight);
RuleFor(x => x.StrategicCoreFloorWeight).InclusiveBetween(0m, 1m);
RuleFor(x => x.SurvivalSellRatioOfLot).InclusiveBetween(0m, 1m);
RuleFor(x => x.GapBelowFloorAtr).GreaterThanOrEqualTo(0m);
RuleFor(x => x.ConsecutiveCloseBreaches).GreaterThanOrEqualTo(0);
RuleFor(x => x.ConcentrationSellRatioOfLot).InclusiveBetween(0m, 1m);
RuleFor(x => x.OpportunitySellRatioOfLot).InclusiveBetween(0m, 1m);
}
}
@@ -0,0 +1,69 @@
using FastEndpoints;
using KArtSell.Modules.SignalEngine.Application;
namespace KArtSell.Modules.SignalEngine.Features.GenerateSellDecision;
public sealed class Endpoint(ISellDecisionService service) : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/internal/v1/signal-decisions");
Roles("Quant", "System");
Description(x => x.WithTags("SignalEngine"));
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
var idempotencyKey = HttpContext.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey) || idempotencyKey.Length > 128)
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://kartsell.local/problems/idempotency-key",
title = "Idempotency-Key is required.",
status = 400
}, ct);
return;
}
var result = await service.GenerateAsync(
new GenerateSellDecisionCommand(
req.PositionLotId,
req.AsOf,
idempotencyKey,
HttpContext.TraceIdentifier),
ct);
if (result is null)
{
HttpContext.Response.StatusCode = StatusCodes.Status409Conflict;
await HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://kartsell.local/problems/approved-evidence-not-found",
title = "Approved point-in-time evidence is not available.",
status = 409
}, ct);
return;
}
await Send.OkAsync(new Response(
result.DecisionId,
result.Action,
result.SellRatioOfLot,
result.TargetSecurityPortfolioWeightAfter,
result.PolicyId,
result.ReasonCode,
result.EvidenceId,
result.DatasetId,
result.ModelVersion,
result.ConfigVersion,
result.CodeSha,
result.DecisionContractVersion,
result.PolicyTraceSchemaVersion,
result.ReentryEligible,
result.PolicyTrace,
result.CreatedAt,
result.Replayed), ct);
}
}
@@ -0,0 +1,8 @@
namespace KArtSell.Modules.SignalEngine.Features.GenerateSellDecision;
/// <summary>
/// Production-shaped command. Evidence/model/config are resolved server-side.
/// </summary>
public sealed record Request(
Guid PositionLotId,
DateTimeOffset AsOf);
@@ -0,0 +1,22 @@
using KArtSell.Modules.SignalEngine.Domain;
namespace KArtSell.Modules.SignalEngine.Features.GenerateSellDecision;
public sealed record Response(
Guid DecisionId,
string Action,
decimal SellRatioOfLot,
decimal TargetSecurityPortfolioWeightAfter,
string PolicyId,
string ReasonCode,
string EvidenceId,
string DatasetId,
string ModelVersion,
string ConfigVersion,
string CodeSha,
string DecisionContractVersion,
int PolicyTraceSchemaVersion,
bool ReentryEligible,
IReadOnlyList<PolicyTraceEntry> PolicyTrace,
DateTimeOffset CreatedAt,
bool Replayed);
@@ -0,0 +1,13 @@
using FastEndpoints;
using FluentValidation;
namespace KArtSell.Modules.SignalEngine.Features.GenerateSellDecision;
public sealed class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.PositionLotId).NotEmpty();
RuleFor(x => x.AsOf).NotEmpty();
}
}
@@ -0,0 +1,58 @@
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.Modules.SignalEngine.Application;
namespace KArtSell.Modules.SignalEngine.Infrastructure;
public sealed class DapperSellDecisionContextReader(IDbConnectionFactory connectionFactory)
: ISellDecisionContextReader
{
private const string Sql = """
select
context_id as ContextId,
position_lot_id as PositionLotId,
cycle_id as CycleId,
evidence_id as EvidenceId,
dataset_id as DatasetId,
model_version as ModelVersion,
config_version as ConfigVersion,
code_sha as CodeSha,
as_of as AsOf,
published_at_cutoff as PublishedAtCutoff,
current_security_portfolio_weight as CurrentSecurityPortfolioWeight,
current_lot_portfolio_weight as CurrentLotPortfolioWeight,
strategic_core_floor_weight as StrategicCoreFloorWeight,
hard_impairment_approved as HardImpairmentApproved,
capital_floor_breached as CapitalFloorBreached,
survival_sell_ratio_of_lot as SurvivalSellRatioOfLot,
gap_below_floor_atr as GapBelowFloorAtr,
consecutive_close_breaches as ConsecutiveCloseBreaches,
cooldown_satisfied as CooldownSatisfied,
concentration_sell_ratio_of_lot as ConcentrationSellRatioOfLot,
opportunity_edge_lower_bound as OpportunityEdgeLowerBound,
opportunity_sell_ratio_of_lot as OpportunitySellRatioOfLot,
quality_status as QualityStatus,
content_hash as ContentHash
from signal_engine.sell_decision_context
where position_lot_id = @PositionLotId
and as_of <= @AsOf
and published_at_cutoff <= @AsOf
and quality_status = 'PASS'
and weight_semantics_version = 2
order by as_of desc, context_id desc
limit 1;
""";
public async Task<SellDecisionContext?> GetApprovedAsync(
Guid positionLotId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<SellDecisionContext>(
new CommandDefinition(
Sql,
new { PositionLotId = positionLotId, AsOf = asOf },
cancellationToken: cancellationToken));
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
<PackageReference Include="FastEndpoints" />
<PackageReference Include="Dapper" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
using KArtSell.Modules.SignalEngine.Application;
using KArtSell.Modules.SignalEngine.Domain;
using KArtSell.Modules.SignalEngine.Domain.Policies;
using KArtSell.Modules.SignalEngine.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace KArtSell.Modules.SignalEngine;
public static class SignalEngineModule
{
public static IServiceCollection AddSignalEngineModule(this IServiceCollection services)
{
services.AddSingleton<ISellPolicy, HardImpairmentPolicy>();
services.AddSingleton<ISellPolicy, PortfolioSurvivalPolicy>();
services.AddSingleton<ISellPolicy, GapFloorBreachPolicy>();
services.AddSingleton<ISellPolicy, TwoCloseFloorBreachPolicy>();
services.AddSingleton<ISellPolicy, ConcentrationLiquidityPolicy>();
services.AddSingleton<ISellPolicy, OpportunityCostPolicy>();
services.AddSingleton<SellPolicyChain>();
services.AddScoped<ISellDecisionContextReader, DapperSellDecisionContextReader>();
services.AddScoped<ISellDecisionService, SellDecisionService>();
return services;
}
}