Initial commit: Add project files
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user