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,16 @@
namespace KArtSell.BuildingBlocks.Auditing;
/// <summary>
/// Append-only audit envelope. Corrections are new events referencing the original event.
/// </summary>
public sealed record AuditEvent(
Guid AuditEventId,
string EntityType,
string EntityId,
string EventType,
string ActorId,
string CorrelationId,
string PayloadJson,
string PayloadHash,
Guid? CorrectsAuditEventId,
DateTimeOffset OccurredAt);
@@ -0,0 +1,11 @@
namespace KArtSell.BuildingBlocks.Capabilities;
public sealed class CapabilityOptions
{
public const string SectionName = "Capabilities";
public bool AutomaticOrder { get; init; }
public bool KisOrderAdapter { get; init; }
public bool ClientPublication { get; init; }
public bool ShadowEvaluation { get; init; }
}
@@ -0,0 +1,8 @@
using System.Data.Common;
namespace KArtSell.BuildingBlocks.Data;
public interface IDbConnectionFactory
{
ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,10 @@
using System.Data.Common;
using Npgsql;
namespace KArtSell.BuildingBlocks.Data;
public sealed class NpgsqlConnectionFactory(NpgsqlDataSource dataSource) : IDbConnectionFactory
{
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken = default)
=> await dataSource.OpenConnectionAsync(cancellationToken);
}
@@ -0,0 +1,39 @@
namespace KArtSell.BuildingBlocks.DataIntegrity;
public enum DataQualityStatus
{
Pass,
Warn,
Quarantined
}
/// <summary>
/// Point-in-time metadata shared by canonical data records.
/// PublishedAt controls the earliest market session in which the record may be used.
/// </summary>
public sealed record PitRecordMetadata(
string SourceId,
DateTimeOffset SourceEventTime,
DateTimeOffset PublishedAt,
DateTimeOffset IngestedAt,
DateTimeOffset EffectiveFrom,
DateTimeOffset? EffectiveTo,
int RevisionNo,
string ContentHash,
string DatasetId,
DataQualityStatus QualityStatus)
{
public void EnsureValid()
{
if (string.IsNullOrWhiteSpace(SourceId)) throw new ArgumentException("SourceId is required.");
if (string.IsNullOrWhiteSpace(ContentHash)) throw new ArgumentException("ContentHash is required.");
if (string.IsNullOrWhiteSpace(DatasetId)) throw new ArgumentException("DatasetId is required.");
if (RevisionNo < 0) throw new ArgumentOutOfRangeException(nameof(RevisionNo));
if (IngestedAt < PublishedAt) throw new ArgumentException("IngestedAt cannot precede PublishedAt.");
if (EffectiveTo is not null && EffectiveTo < EffectiveFrom)
throw new ArgumentException("EffectiveTo cannot precede EffectiveFrom.");
}
public bool IsUsableAt(DateTimeOffset decisionCutoff)
=> QualityStatus is DataQualityStatus.Pass && PublishedAt <= decisionCutoff;
}
@@ -0,0 +1,19 @@
using KArtSell.BuildingBlocks.Versioning;
namespace KArtSell.BuildingBlocks.Execution;
public sealed record ExecutionEnvelope(
DateTimeOffset AsOf,
DateTimeOffset PublishedAtCutoff,
string ScopeKey,
string IdempotencyKey,
string CorrelationId,
VersionSet VersionSet,
string ContentHash)
{
public void EnsureValid()
{
if (PublishedAtCutoff > AsOf) throw new InvalidOperationException("PublishedAtCutoff cannot be after AsOf.");
if (string.IsNullOrWhiteSpace(ScopeKey) || string.IsNullOrWhiteSpace(IdempotencyKey) || string.IsNullOrWhiteSpace(CorrelationId) || string.IsNullOrWhiteSpace(ContentHash))
throw new InvalidOperationException("Execution envelope identifiers are required.");
VersionSet.EnsureValid();
}
}
@@ -0,0 +1,10 @@
using System.Security.Cryptography;
using System.Text;
namespace KArtSell.BuildingBlocks.Hashing;
public static class ContentHasher
{
public static string Sha256(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Dapper" />
<PackageReference Include="Npgsql" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
namespace KArtSell.BuildingBlocks.ReadModels;
public sealed record ProjectionCheckpoint(
string ProjectionName,
int ProjectionVersion,
string SourceWatermark,
Guid? LastEventId,
string ContentHash,
DateTimeOffset BuiltAt)
{
public bool IsStale(DateTimeOffset now, TimeSpan maximumAge) => now - BuiltAt > maximumAge;
}
public sealed record ProjectionRebuildRequest(
Guid RebuildId,
string ProjectionName,
int TargetVersion,
string ThroughWatermark,
bool DryRun,
string RequestedBy,
DateTimeOffset RequestedAt);
@@ -0,0 +1,12 @@
namespace KArtSell.BuildingBlocks.ReadModels;
public enum ProjectionHealth { Current, Stale, Rebuilding, Failed, Quarantined }
public sealed record ProjectionContract(string Name, int Version, string SourceWatermark, DateTimeOffset BuiltAt, TimeSpan StaleAfter, string Owner)
{
public ProjectionHealth Evaluate(DateTimeOffset now, bool rebuilding = false, bool failed = false, bool quarantined = false)
{
if (quarantined) return ProjectionHealth.Quarantined;
if (failed) return ProjectionHealth.Failed;
if (rebuilding) return ProjectionHealth.Rebuilding;
return now - BuiltAt > StaleAfter ? ProjectionHealth.Stale : ProjectionHealth.Current;
}
}
@@ -0,0 +1,31 @@
using System.Data.Common;
using Dapper;
namespace KArtSell.BuildingBlocks.Reliability;
public sealed class DapperInboxStore : IInboxStore
{
private const string Sql = """
insert into building_blocks.inbox_message
(consumer, message_id, received_at, payload_hash)
values (@Consumer, @MessageId, @ReceivedAt, @PayloadHash)
on conflict (consumer, message_id) do nothing;
""";
public async Task<bool> TryBeginAsync(
DbConnection connection,
DbTransaction transaction,
string consumer,
Guid messageId,
string payloadHash,
DateTimeOffset receivedAt,
CancellationToken cancellationToken)
{
var affected = await connection.ExecuteAsync(new CommandDefinition(
Sql,
new { Consumer = consumer, MessageId = messageId, ReceivedAt = receivedAt, PayloadHash = payloadHash },
transaction,
cancellationToken: cancellationToken));
return affected == 1;
}
}
@@ -0,0 +1,93 @@
using Dapper;
using KArtSell.BuildingBlocks.Data;
namespace KArtSell.BuildingBlocks.Reliability;
public sealed class DapperJobRunRepository(IDbConnectionFactory connectionFactory) : IJobRunRepository
{
private const string InsertSql = """
insert into building_blocks.job_run
(job_run_id, job_type, job_version, scope_key, idempotency_key, watermark,
app_version, model_version, config_version, data_version, contract_version,
status, input_hash, started_at, heartbeat_at, trace_id)
values
(@JobRunId, @JobType, @JobVersion, @ScopeKey, @IdempotencyKey, @Watermark,
@CodeSha, @ModelVersion, @ConfigVersion, @DatasetId, @ContractVersion,
'Running', @InputHash, @RequestedAt, @RequestedAt, @TraceId)
on conflict (idempotency_key) do nothing;
""";
private const string HeartbeatSql = """
update building_blocks.job_run
set heartbeat_at = @At
where job_run_id = @JobRunId and status = 'Running';
""";
private const string CompleteSql = """
update building_blocks.job_run
set status = @Status,
output_hash = @OutputHash,
watermark = coalesce(@Watermark, watermark),
error_code = @ErrorCode,
finished_at = @FinishedAt,
heartbeat_at = @FinishedAt
where job_run_id = @JobRunId and status = 'Running';
""";
public async Task<bool> TryStartAsync(JobRunStart start, CancellationToken cancellationToken)
{
start.VersionSet.EnsureValid();
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(
InsertSql,
new
{
start.JobRunId,
start.JobType,
start.JobVersion,
start.ScopeKey,
start.IdempotencyKey,
start.Watermark,
start.VersionSet.CodeSha,
start.VersionSet.ModelVersion,
start.VersionSet.ConfigVersion,
start.VersionSet.DatasetId,
start.VersionSet.ContractVersion,
start.InputHash,
start.TraceId,
start.RequestedAt
},
cancellationToken: cancellationToken));
return affected == 1;
}
public async Task HeartbeatAsync(Guid jobRunId, DateTimeOffset at, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(
HeartbeatSql,
new { JobRunId = jobRunId, At = at },
cancellationToken: cancellationToken));
}
public async Task CompleteAsync(JobRunCompletion completion, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(
CompleteSql,
new
{
completion.JobRunId,
Status = completion.Status.ToString(),
completion.OutputHash,
completion.Watermark,
completion.ErrorCode,
completion.FinishedAt
},
cancellationToken: cancellationToken));
if (affected != 1)
{
throw new InvalidOperationException("Job run is absent or no longer Running.");
}
}
}
@@ -0,0 +1,17 @@
using System.Data.Common;
using Dapper;
namespace KArtSell.BuildingBlocks.Reliability;
public sealed class DapperOutboxWriter : IOutboxWriter
{
private const string Sql = """
insert into building_blocks.outbox_message
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash)
values (@MessageId, @EventType, @SchemaVersion, cast(@PayloadJson as jsonb), @CorrelationId, @OccurredAt, @PayloadHash)
on conflict (message_id) do nothing;
""";
public Task AddAsync(DbConnection connection, DbTransaction transaction, OutboxMessage message, CancellationToken cancellationToken)
=> connection.ExecuteAsync(new CommandDefinition(Sql, message, transaction, cancellationToken: cancellationToken));
}
@@ -0,0 +1,15 @@
using System.Data.Common;
namespace KArtSell.BuildingBlocks.Reliability;
public interface IInboxStore
{
Task<bool> TryBeginAsync(
DbConnection connection,
DbTransaction transaction,
string consumer,
Guid messageId,
string payloadHash,
DateTimeOffset receivedAt,
CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace KArtSell.BuildingBlocks.Reliability;
public interface IJobRunRepository
{
Task<bool> TryStartAsync(JobRunStart start, CancellationToken cancellationToken);
Task HeartbeatAsync(Guid jobRunId, DateTimeOffset at, CancellationToken cancellationToken);
Task CompleteAsync(JobRunCompletion completion, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
using System.Data.Common;
namespace KArtSell.BuildingBlocks.Reliability;
public interface IOutboxWriter
{
Task AddAsync(DbConnection connection, DbTransaction transaction, OutboxMessage message, CancellationToken cancellationToken);
}
@@ -0,0 +1,34 @@
using KArtSell.BuildingBlocks.Versioning;
namespace KArtSell.BuildingBlocks.Reliability;
public enum JobRunStatus
{
Requested,
Running,
Succeeded,
BusinessHold,
Quarantined,
Failed,
DeadLettered
}
public sealed record JobRunStart(
Guid JobRunId,
string JobType,
int JobVersion,
string ScopeKey,
string IdempotencyKey,
string? Watermark,
VersionSet VersionSet,
string? InputHash,
string TraceId,
DateTimeOffset RequestedAt);
public sealed record JobRunCompletion(
Guid JobRunId,
JobRunStatus Status,
string? OutputHash,
string? Watermark,
string? ErrorCode,
DateTimeOffset FinishedAt);
@@ -0,0 +1,10 @@
namespace KArtSell.BuildingBlocks.Reliability;
public sealed record OutboxMessage(
Guid MessageId,
string EventType,
int SchemaVersion,
string PayloadJson,
string CorrelationId,
DateTimeOffset OccurredAt,
string PayloadHash);
@@ -0,0 +1,11 @@
namespace KArtSell.BuildingBlocks.Time;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
@@ -0,0 +1,32 @@
namespace KArtSell.BuildingBlocks.Versioning;
/// <summary>
/// Immutable identity of the data, model, configuration and code used for a decision.
/// Values are opaque, approved identifiers or SHA-256 digests; they are never inferred from the client.
/// </summary>
public sealed record VersionSet(
string DatasetId,
string DataHash,
string ModelVersion,
string ConfigVersion,
string CodeSha,
string ContractVersion)
{
public void EnsureValid()
{
EnsureNotBlank(DatasetId, nameof(DatasetId));
EnsureNotBlank(DataHash, nameof(DataHash));
EnsureNotBlank(ModelVersion, nameof(ModelVersion));
EnsureNotBlank(ConfigVersion, nameof(ConfigVersion));
EnsureNotBlank(CodeSha, nameof(CodeSha));
EnsureNotBlank(ContractVersion, nameof(ContractVersion));
}
private static void EnsureNotBlank(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} must be an approved, non-blank identifier.", name);
}
}
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="DbUp-PostgreSQL" />
</ItemGroup>
<ItemGroup>
<Content Include="../../db/migrations/**/*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+29
View File
@@ -0,0 +1,29 @@
using DbUp;
var connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
?? "Host=localhost;Port=5432;Database=kartsell;Username=kartsell;Password=kartsell";
var scriptsPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations");
if (!Directory.Exists(scriptsPath))
{
scriptsPath = Path.Combine(AppContext.BaseDirectory, "migrations");
}
EnsureDatabase.For.PostgresqlDatabase(connectionString);
var result = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsFromFileSystem(scriptsPath)
.JournalToPostgresqlTable("public", "kartsell_schema_versions")
.LogToConsole()
.Build()
.PerformUpgrade();
if (!result.Successful)
{
Console.Error.WriteLine(result.Error);
return 1;
}
Console.WriteLine("Database migration completed.");
return 0;
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
<ProjectReference Include="../KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
<ProjectReference Include="../KArtSell.Modules.ModelOperations/KArtSell.Modules.ModelOperations.csproj" />
<PackageReference Include="FastEndpoints" />
<PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Polly" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
</Project>
+147
View File
@@ -0,0 +1,147 @@
using FastEndpoints;
using Hangfire;
using Hangfire.PostgreSql;
using KArtSell.BuildingBlocks.Capabilities;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Reliability;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Host.Security;
using KArtSell.Modules.ModelOperations;
using KArtSell.Modules.ModelOperations.Scheduling;
using KArtSell.Modules.SignalEngine;
using Microsoft.AspNetCore.Authentication;
using Npgsql;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, logger) => logger
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
var connectionString = builder.Configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("ConnectionStrings:Postgres is required.");
var modelOperationsDispatcherEnabled = builder.Configuration.GetValue<bool>("ModelOperations:DispatcherEnabled");
var modelOperationsDispatcherCron = builder.Configuration["ModelOperations:DispatcherCron"] ?? "*/15 * * * *";
builder.Services.AddOptions<CapabilityOptions>()
.Bind(builder.Configuration.GetSection(CapabilityOptions.SectionName))
.Validate(x => !x.AutomaticOrder, "AutomaticOrder must remain OFF in this package.")
.Validate(x => !x.KisOrderAdapter, "KisOrderAdapter must remain OFF until a separately approved release.")
.Validate(x => !modelOperationsDispatcherEnabled || x.ShadowEvaluation,
"ModelOperations dispatcher requires ShadowEvaluation capability and remains evidence-only.")
.ValidateOnStart();
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
builder.Services.AddSingleton(dataSource);
builder.Services.AddSingleton<IDbConnectionFactory, NpgsqlConnectionFactory>();
builder.Services.AddSingleton<IOutboxWriter, DapperOutboxWriter>();
builder.Services.AddSingleton<IInboxStore, DapperInboxStore>();
builder.Services.AddSingleton<IJobRunRepository, DapperJobRunRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddProblemDetails();
builder.Services.AddFastEndpoints();
const string authenticationScheme = "KArtSell";
var authenticationMode = builder.Configuration["Authentication:Mode"] ?? "FailClosed";
var authenticationBuilder = builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = authenticationScheme;
options.DefaultChallengeScheme = authenticationScheme;
});
if (builder.Environment.IsDevelopment()
&& authenticationMode.Equals("DevelopmentHeader", StringComparison.OrdinalIgnoreCase))
{
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, DevelopmentHeaderAuthenticationHandler>(
authenticationScheme,
_ => { });
}
else
{
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, FailClosedAuthenticationHandler>(
authenticationScheme,
_ => { });
}
builder.Services.AddAuthorization();
builder.Services.AddSignalEngineModule();
builder.Services.AddModelOperationsModule();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHangfire(config => config.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(connectionString)));
builder.Services.AddHangfireServer(options =>
{
options.Queues =
[
"q-control",
"q-market-data",
"q-fundamentals",
"q-feature-risk",
"q-recommendation",
"q-evaluation",
"q-reconciliation",
"q-research",
"q-backfill"
];
options.WorkerCount = Math.Max(2, Environment.ProcessorCount / 2);
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("KArtSell.Host"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints(config => config.Endpoints.RoutePrefix = "api");
app.Services.RegisterModelOperationsSchedules(modelOperationsDispatcherEnabled, modelOperationsDispatcherCron);
app.MapGet("/health/live", () => Results.Ok(new
{
status = "ok",
automaticOrderCapability = "OFF",
algorithmStatus = "RESEARCH_CANDIDATE_NOT_PRODUCTION"
}));
app.MapGet("/health/ready", async (NpgsqlDataSource source, CancellationToken ct) =>
{
await using var connection = await source.OpenConnectionAsync(ct);
await using var command = connection.CreateCommand();
command.CommandText = "select 1";
await command.ExecuteScalarAsync(ct);
return Results.Ok(new { status = "ready", database = "reachable" });
});
app.Run();
public partial class Program;
@@ -0,0 +1,46 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace KArtSell.Host.Security;
/// <summary>
/// Development-only authentication. Never enable outside Development.
/// Required headers: X-KArtSell-User and X-KArtSell-Role.
/// </summary>
public sealed class DevelopmentHeaderAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IWebHostEnvironment environment)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!environment.IsDevelopment())
{
return Task.FromResult(AuthenticateResult.Fail(
"Development header authentication is disabled outside Development."));
}
var user = Request.Headers["X-KArtSell-User"].ToString();
var role = Request.Headers["X-KArtSell-Role"].ToString();
if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(role))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user),
new Claim(ClaimTypes.Name, user),
new Claim(ClaimTypes.Role, role),
new Claim("auth_mode", "development_header")
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(principal, Scheme.Name)));
}
}
@@ -0,0 +1,22 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace KArtSell.Host.Security;
public sealed class FailClosedAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
=> Task.FromResult(AuthenticateResult.Fail(
"Authentication provider is not configured. The service is fail-closed."));
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
}
@@ -0,0 +1,8 @@
{
"Authentication": {
"Mode": "DevelopmentHeader"
},
"ModelOperations": {
"DispatcherEnabled": false
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=kartsell;Username=kartsell;Password=kartsell"
},
"Authentication": {
"Mode": "FailClosed"
},
"Capabilities": {
"AutomaticOrder": false,
"KisOrderAdapter": false,
"ClientPublication": false,
"ShadowEvaluation": true
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
},
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
"ModelOperations": {
"DispatcherEnabled": false,
"DispatcherCron": "*/15 * * * *",
"Boundary": "EVIDENCE_ONLY_NO_AUTO_MODEL_OR_ORDER_MUTATION"
}
}
@@ -0,0 +1,44 @@
using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.ModelOperations.Domain;
namespace KArtSell.Modules.ModelOperations.Application;
public sealed class ModelOperationRequestService(
IApprovedModelContextReader contextReader,
IModelOperationRequestRepository repository,
IClock clock) : IModelOperationRequestService
{
public async Task<ModelOperationRequest?> RequestAsync(
Guid scheduleId,
string operationCode,
string scopeKey,
string automationMode,
string idempotencyKey,
string correlationId,
CancellationToken cancellationToken)
{
var definition = ModelOperationRegistry.GetRequired(operationCode);
ModelOperationExecutionBoundary.EnsureAllowed(definition);
if (!definition.AutomationMode.ToContractValue().Equals(automationMode, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Schedule automation mode does not match the approved operation registry.");
var now = clock.UtcNow;
var context = await contextReader.ReadAsync(scopeKey, now, cancellationToken);
if (context is null)
return null;
context.VersionSet.EnsureValid();
var request = new ModelOperationRequest(
Guid.NewGuid(),
scheduleId,
definition.OperationCode,
scopeKey,
definition.AutomationMode.ToContractValue(),
idempotencyKey,
context,
correlationId,
now);
return await repository.AddAsync(request, cancellationToken) ? request : null;
}
}
@@ -0,0 +1,80 @@
using KArtSell.BuildingBlocks.Versioning;
namespace KArtSell.Modules.ModelOperations.Application;
public sealed record ApprovedModelContext(
string ScopeKey,
VersionSet VersionSet,
string LifecycleState,
DateTimeOffset EffectiveAt);
public sealed record DueModelOperation(
Guid ScheduleId,
string OperationCode,
string ScopeKey,
string Cadence,
string AutomationMode,
string Queue,
string IdempotencyKey,
int ScheduleVersion,
DateTimeOffset ScheduledFor,
string CatchUpPolicy,
int MaxCatchUp);
public sealed record ModelOperationRequest(
Guid RequestId,
Guid ScheduleId,
string OperationCode,
string ScopeKey,
string AutomationMode,
string IdempotencyKey,
ApprovedModelContext Context,
string CorrelationId,
DateTimeOffset RequestedAt);
public interface IApprovedModelContextReader
{
Task<ApprovedModelContext?> ReadAsync(string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken);
}
public interface IModelScheduleRepository
{
Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(
DateTimeOffset now,
string leaseOwner,
TimeSpan leaseDuration,
int limit,
CancellationToken cancellationToken);
Task MarkDispatchedAsync(
Guid scheduleId,
string leaseOwner,
string backgroundJobId,
DateTimeOffset dispatchedAt,
DateTimeOffset nextDueAt,
CancellationToken cancellationToken);
Task ReleaseAsync(
Guid scheduleId,
string leaseOwner,
string reasonCode,
DateTimeOffset releasedAt,
CancellationToken cancellationToken);
}
public interface IModelOperationRequestRepository
{
Task<bool> AddAsync(ModelOperationRequest request, CancellationToken cancellationToken);
}
public interface IModelOperationRequestService
{
Task<ModelOperationRequest?> RequestAsync(
Guid scheduleId,
string operationCode,
string scopeKey,
string automationMode,
string idempotencyKey,
string correlationId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public enum EvaluationReconciliationAction { None, PlanMissing, QuarantineDuplicate, BusinessHoldLate, RecalculateRevision }
public sealed record EvaluationWindowFact(int TradingDays, bool Planned, bool Duplicate, bool Matured, bool Evaluated, bool SourceRevisionChanged);
public static class EvaluationReconciliationPlanner
{
private static readonly int[] RequiredWindows = [1,5,20,63,126,252];
public static IReadOnlyDictionary<int, EvaluationReconciliationAction> Plan(IEnumerable<EvaluationWindowFact> facts)
{
var byWindow=facts.GroupBy(x=>x.TradingDays).ToDictionary(x=>x.Key,x=>x.ToArray());
var result=new Dictionary<int,EvaluationReconciliationAction>();
foreach (var window in RequiredWindows)
{
if (!byWindow.TryGetValue(window,out var rows) || rows.Length==0) { result[window]=EvaluationReconciliationAction.PlanMissing; continue; }
if (rows.Length>1 || rows.Any(x=>x.Duplicate)) { result[window]=EvaluationReconciliationAction.QuarantineDuplicate; continue; }
var fact=rows[0];
if (fact.SourceRevisionChanged) { result[window]=EvaluationReconciliationAction.RecalculateRevision; continue; }
if (fact.Matured && !fact.Evaluated) { result[window]=EvaluationReconciliationAction.BusinessHoldLate; continue; }
result[window]=EvaluationReconciliationAction.None;
}
return result;
}
}
@@ -0,0 +1,28 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public interface ITradingSessionCalendar
{
DateOnly AddTradingSessions(string calendarId, DateOnly startSession, int sessionCount);
}
public sealed record EvaluationWindowDue(int WindowTradingDays, DateOnly DueSession, string WindowCode);
public sealed class EvaluationWindowPlanner
{
public static readonly int[] ApprovedWindows = [1, 5, 20, 63, 126, 252];
public IReadOnlyList<EvaluationWindowDue> Plan(
string calendarId,
DateOnly predictionSession,
ITradingSessionCalendar calendar)
{
ArgumentException.ThrowIfNullOrWhiteSpace(calendarId);
ArgumentNullException.ThrowIfNull(calendar);
return ApprovedWindows
.Select(window => new EvaluationWindowDue(
window,
calendar.AddTradingSessions(calendarId, predictionSession, window),
$"W{window:D3}"))
.ToArray();
}
}
@@ -0,0 +1,21 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public sealed record MetricDefinitionVersion(
string MetricCode,
int DefinitionVersion,
string CohortDefinitionHash,
string WindowDefinition,
string AggregationMethod,
string ThresholdContractHash,
DateTimeOffset EffectiveFrom)
{
public void EnsureValid()
{
ArgumentException.ThrowIfNullOrWhiteSpace(MetricCode);
if (DefinitionVersion <= 0) throw new InvalidOperationException("Metric definition version must be positive.");
ArgumentException.ThrowIfNullOrWhiteSpace(CohortDefinitionHash);
ArgumentException.ThrowIfNullOrWhiteSpace(WindowDefinition);
ArgumentException.ThrowIfNullOrWhiteSpace(AggregationMethod);
ArgumentException.ThrowIfNullOrWhiteSpace(ThresholdContractHash);
}
}
@@ -0,0 +1,60 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public enum ModelLifecycleState
{
Research,
Challenger,
Shadow,
Candidate,
Approved,
Retired,
RolledBack
}
public enum GateDecision
{
Pass,
Warn,
Hold,
Fail
}
public sealed record ModelEvaluationSnapshot(
string ScopeKey,
string ModelVersion,
int ShadowTradingDays,
int DistinctRegimes,
decimal NetExpectedUtility,
decimal FalseExitAnnualRate,
decimal ReentryCaptureRate,
decimal Pbo,
decimal Dsr,
bool PositiveUnderDoubleCost,
int OperationalIntegrityErrors,
bool CalibrationStable,
DateTimeOffset AsOf);
public sealed record PromotionGateThresholds(
int MinimumShadowTradingDays,
int MinimumDistinctRegimes,
decimal MaximumFalseExitAnnualRate,
decimal MinimumReentryCaptureRate,
decimal MaximumPbo,
decimal MinimumDsr)
{
public static PromotionGateThresholds ResearchBaseline { get; } = new(
MinimumShadowTradingDays: 252,
MinimumDistinctRegimes: 2,
MaximumFalseExitAnnualRate: 0.02m,
MinimumReentryCaptureRate: 0.65m,
MaximumPbo: 0.20m,
MinimumDsr: 0.95m);
}
public sealed record PromotionGateResult(
GateDecision Decision,
IReadOnlyList<string> BlockingReasons,
IReadOnlyList<string> Warnings,
bool RequiresIndependentValidation,
bool RequiresHumanApproval,
string Boundary);
@@ -0,0 +1,117 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public enum ModelOperationCadence
{
Daily,
Weekly,
Monthly,
Quarterly,
EventDriven
}
public enum AutomationMode
{
EvaluationOnly,
ProposalOnly,
DrillOnly
}
public static class ModelOperationContractValues
{
public static string ToContractValue(this AutomationMode mode) => mode switch
{
AutomationMode.EvaluationOnly => "EVALUATION_ONLY",
AutomationMode.ProposalOnly => "PROPOSAL_ONLY",
AutomationMode.DrillOnly => "DRILL_ONLY",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null)
};
public static string ToContractValue(this ModelOperationCadence cadence) => cadence switch
{
ModelOperationCadence.Daily => "DAILY",
ModelOperationCadence.Weekly => "WEEKLY",
ModelOperationCadence.Monthly => "MONTHLY",
ModelOperationCadence.Quarterly => "QUARTERLY",
ModelOperationCadence.EventDriven => "EVENT_DRIVEN",
_ => throw new ArgumentOutOfRangeException(nameof(cadence), cadence, null)
};
}
public sealed record ModelOperationDefinition(
string OperationCode,
string Name,
ModelOperationCadence Cadence,
AutomationMode AutomationMode,
string Queue,
string PrimaryOwner,
string SecondaryOwner,
string RequiredEvidence,
string Output,
string Gate);
public static class ModelOperationRegistry
{
public static IReadOnlyList<ModelOperationDefinition> All { get; } =
[
new("J10", "OutcomeEvaluationRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Ops", "Data/QA", "due 1/5/20/63/126/252 windows", "versioned outcome observations", "G3"),
new("J11", "DailyScorecardBuild", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Ops", "Risk/SRE", "completed outcomes and operational metrics", "daily cohort scorecard", "G3"),
new("J17", "DriftDetectionRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Risk", "Data/SRE", "feature, prediction and data-quality baselines", "drift observations and hold alerts", "G4"),
new("J18", "ChampionChallengerEvaluation", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Risk", "QA/InvestmentCommittee", "same frozen data, cost and cohort definitions", "paired champion/challenger comparison", "G4"),
new("J19", "FrozenOosBacktest", ModelOperationCadence.Monthly, AutomationMode.EvaluationOnly,
"q-research", "Quant/Data", "QA/Risk", "dataset, code, config, seed and environment manifest", "walk-forward OOS evidence bundle", "G4"),
new("J20", "RobustnessPboDsrRun", ModelOperationCadence.Quarterly, AutomationMode.EvaluationOnly,
"q-research", "Quant/Risk", "IndependentValidation", "experiment registry, CSCV and block-bootstrap inputs", "PBO/DSR/stability/cost-stress report", "G4"),
new("J21", "ModelImprovementProposalBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
"q-research", "Quant Lead", "Risk/Architect", "scorecard, drift, attribution and debt evidence", "review-required improvement proposal", "G4"),
new("J22", "PromotionEvidenceReviewBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
"q-control", "Risk/InvestmentCommittee", "Compliance/QA", "all promotion-gate evidence and independent validation", "maker-checker review packet", "G4"),
new("J23", "ModelRollbackDrill", ModelOperationCadence.Quarterly, AutomationMode.DrillOnly,
"q-control", "SRE/Risk", "Module Owner/QA", "approved champion manifest and rollback runbook", "rollback drill evidence", "G5"),
new("J24", "DataRevisionRevalidation", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
"q-backfill", "Data/Quant", "DBA/QA", "revision lineage and affected-decision index", "replay impact and correction proposal", "G3"),
new("J25", "SourceContractDriftCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Data Governance", "Adapter Owner/QA", "approved source schema, license and SLA snapshot", "source contract drift evidence and hold", "G1"),
new("J26", "MarketCalendarCompletenessCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-market-data", "Data/Ops", "Quant/QA", "approved KRX/NYSE/NASDAQ calendar and source completeness", "tradable-session readiness evidence", "G1"),
new("J27", "EvidenceChainAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-control", "Compliance/QA", "Data/BE", "decision-to-source hash chain and immutable audit records", "missing-link and mutation evidence", "G3"),
new("J28", "ProjectionFreshnessCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "BE/Data", "SRE/QA", "projection version, watermark and rebuild contract", "staleness and rebuild-diff evidence", "G3"),
new("J29", "CapacitySlaTrend", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
"q-control", "SRE/PM", "DBA/Module Owner", "approved volume assumptions and SLO definitions", "capacity trend and scaling decision packet", "G5"),
new("J30", "ReleaseEvidenceAssemble", ModelOperationCadence.EventDriven, AutomationMode.ProposalOnly,
"q-control", "QA/Release Manager", "Compliance/SRE", "approved build, test, migration, security and rollback artifacts", "review-required release evidence bundle", "G6"),
new("J31", "PredictionFreezeRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Data", "QA/Risk", "approved PIT context before market cutoff", "immutable prediction freeze and VersionSet hash", "G3"),
new("J32", "OutcomeMaturitySweep", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Ops", "Data/QA", "trading-session calendar and due evaluation windows", "matured 1/5/20/63/126/252 evaluation requests", "G3"),
new("J33", "SellReentryAttributionReview", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Risk", "Advisor/QA", "matured sell and reentry outcomes with cost and benchmark", "false-exit, gain-capture, capture-rate and delay-cost evidence", "G4"),
new("J34", "CalibrationRegimeReview", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/Risk", "Data/IndependentValidation", "versioned cohort, regime and prediction distribution definitions", "calibration, coverage, drift and regime stability evidence", "G4"),
new("J35", "ImprovementHypothesisBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
"q-research", "Quant Lead", "Risk/Architect", "scorecard, attribution, drift, incidents and debt evidence", "review-required improvement hypothesis with falsification test", "G4"),
new("J36", "ChallengerExperimentPlan", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
"q-research", "Quant Lead", "IndependentValidation/QA", "approved hypothesis and frozen experiment registry", "review-required challenger experiment plan; no activation", "G4"),
new("J37", "IndependentValidationPack", ModelOperationCadence.Quarterly, AutomationMode.ProposalOnly,
"q-control", "IndependentValidation", "Risk/Compliance", "OOS, PBO, DSR, cost stress, reproducibility and operations evidence", "independent validation decision packet", "G4"),
new("J38", "ModelPolicyDebtReview", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
"q-control", "Risk/Architect", "Quant/PM", "model, policy, data and technical debt register", "prioritized remediation and expiration decisions", "G5"),
new("J39", "FeedbackCycleIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-control", "Compliance/QA", "Quant/SRE", "feedback-cycle state, transition sequence, evidence hashes and aging policy", "stuck-cycle, illegal-transition and evidence-gap report", "G4"),
new("J40", "EvaluationWindowIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-control", "Quant/QA", "Data/SRE", "prediction evidence, approved trading calendar and 1/5/20/63/126/252 window plan", "missing, duplicate, late and calendar-drift window report", "G4"),
new("J41", "SchedulerLeaseIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-control", "SRE/QA", "BE/DBA", "schedule leases, fencing tokens, heartbeat and dispatch revisions", "expired, overlapping and stale-token lease report", "G3"),
new("J42", "ModelEvaluationReconciliation", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
"q-evaluation", "Quant/QA", "Data/Risk", "prediction windows, outcomes, source revisions and metric definitions", "missing, duplicate, late and revision-recalculation plan", "G4")
];
public static ModelOperationDefinition GetRequired(string operationCode)
=> All.SingleOrDefault(x => x.OperationCode.Equals(operationCode, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentOutOfRangeException(nameof(operationCode), operationCode, "Unknown model operation code.");
}
@@ -0,0 +1,67 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public enum ModelOperationExecutionState
{
Requested,
Running,
Succeeded,
BusinessHold,
Failed,
Quarantined
}
public sealed record ModelOperationExecutionTransition(
ModelOperationExecutionState From,
ModelOperationExecutionState To,
string ReasonCode,
string EvidenceHash,
DateTimeOffset OccurredAt);
public sealed class ModelOperationExecution
{
private static readonly IReadOnlyDictionary<ModelOperationExecutionState, IReadOnlySet<ModelOperationExecutionState>> Allowed =
new Dictionary<ModelOperationExecutionState, IReadOnlySet<ModelOperationExecutionState>>
{
[ModelOperationExecutionState.Requested] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.BusinessHold, ModelOperationExecutionState.Quarantined),
[ModelOperationExecutionState.Running] = Set(ModelOperationExecutionState.Succeeded, ModelOperationExecutionState.BusinessHold, ModelOperationExecutionState.Failed, ModelOperationExecutionState.Quarantined),
[ModelOperationExecutionState.BusinessHold] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.Quarantined),
[ModelOperationExecutionState.Failed] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.Quarantined),
[ModelOperationExecutionState.Succeeded] = new HashSet<ModelOperationExecutionState>(),
[ModelOperationExecutionState.Quarantined] = new HashSet<ModelOperationExecutionState>()
};
private readonly List<ModelOperationExecutionTransition> transitions = [];
public ModelOperationExecution(Guid requestId, string idempotencyKey, DateTimeOffset requestedAt)
{
if (requestId == Guid.Empty) throw new ArgumentException("Request ID is required.", nameof(requestId));
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
RequestId = requestId;
IdempotencyKey = idempotencyKey;
RequestedAt = requestedAt;
LastOccurredAt = requestedAt;
State = ModelOperationExecutionState.Requested;
}
public Guid RequestId { get; }
public string IdempotencyKey { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset LastOccurredAt { get; private set; }
public ModelOperationExecutionState State { get; private set; }
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
public ModelOperationExecutionTransition MoveTo(ModelOperationExecutionState next, string reasonCode, string evidenceHash, DateTimeOffset occurredAt)
{
if (!Allowed[State].Contains(next)) throw new InvalidOperationException($"Execution transition {State} -> {next} is not allowed.");
ArgumentException.ThrowIfNullOrWhiteSpace(reasonCode);
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceHash);
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Execution transition time cannot move backwards.");
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt);
transitions.Add(transition);
State = next;
LastOccurredAt = occurredAt;
return transition;
}
private static IReadOnlySet<ModelOperationExecutionState> Set(params ModelOperationExecutionState[] states) => new HashSet<ModelOperationExecutionState>(states);
}
@@ -0,0 +1,27 @@
namespace KArtSell.Modules.ModelOperations.Domain;
/// <summary>
/// Central fail-closed boundary for scheduled model operations. The scheduler may request evidence,
/// proposals or drills only; it may never mutate model/code/configuration, publish client advice,
/// submit broker orders or change a model lifecycle state.
/// </summary>
public static class ModelOperationExecutionBoundary
{
public const string BoundaryCode = "EVIDENCE_ONLY_NO_AUTO_MODEL_OR_ORDER_MUTATION";
public static void EnsureAllowed(ModelOperationDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
if (definition.AutomationMode is not (
AutomationMode.EvaluationOnly or AutomationMode.ProposalOnly or AutomationMode.DrillOnly))
{
throw new InvalidOperationException($"Operation {definition.OperationCode} violates {BoundaryCode}.");
}
if (!definition.Queue.StartsWith("q-", StringComparison.Ordinal))
{
throw new InvalidOperationException($"Operation {definition.OperationCode} has an unapproved queue.");
}
}
}
@@ -0,0 +1,30 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public readonly record struct LeaseFencingToken(long Value)
{
public static LeaseFencingToken Next(LeaseFencingToken current) => new(checked(current.Value + 1));
}
public sealed class ModelOperationLease
{
public string OperationCode { get; }
public string ScopeKey { get; }
public LeaseFencingToken Token { get; private set; }
public DateTimeOffset ExpiresAt { get; private set; }
public string Owner { get; private set; }
public ModelOperationLease(string operationCode, string scopeKey, LeaseFencingToken token, string owner, DateTimeOffset expiresAt)
{
if (string.IsNullOrWhiteSpace(operationCode) || string.IsNullOrWhiteSpace(scopeKey) || string.IsNullOrWhiteSpace(owner)) throw new ArgumentException("Lease identity is required.");
OperationCode=operationCode; ScopeKey=scopeKey; Token=token; Owner=owner; ExpiresAt=expiresAt;
}
public void Renew(LeaseFencingToken expectedToken, string owner, DateTimeOffset newExpiresAt)
{
if (expectedToken != Token || owner != Owner) throw new InvalidOperationException("Stale lease fencing token or owner.");
if (newExpiresAt <= ExpiresAt) throw new InvalidOperationException("Lease renewal must extend expiry.");
ExpiresAt=newExpiresAt;
}
public LeaseFencingToken Transfer(DateTimeOffset now, string newOwner, DateTimeOffset newExpiresAt)
{
if (now < ExpiresAt) throw new InvalidOperationException("Active lease cannot be transferred.");
if (newExpiresAt <= now) throw new InvalidOperationException("New lease must expire in the future.");
Token=LeaseFencingToken.Next(Token); Owner=newOwner; ExpiresAt=newExpiresAt; return Token;
}
}
@@ -0,0 +1,45 @@
namespace KArtSell.Modules.ModelOperations.Domain;
/// <summary>
/// Evidence gate only. It never activates, promotes, rolls back or mutates a model.
/// </summary>
public sealed class PromotionGateEvaluator
{
public PromotionGateResult Evaluate(ModelEvaluationSnapshot snapshot, PromotionGateThresholds thresholds)
{
var blockers = new List<string>();
var warnings = new List<string>();
if (snapshot.OperationalIntegrityErrors != 0)
blockers.Add("Operational integrity errors must be zero.");
if (snapshot.ShadowTradingDays < thresholds.MinimumShadowTradingDays)
blockers.Add($"Shadow evidence requires at least {thresholds.MinimumShadowTradingDays} trading days.");
if (snapshot.DistinctRegimes < thresholds.MinimumDistinctRegimes)
blockers.Add($"At least {thresholds.MinimumDistinctRegimes} distinct market regimes are required.");
if (snapshot.NetExpectedUtility <= 0)
blockers.Add("Net expected utility after costs must be positive.");
if (!snapshot.CalibrationStable)
blockers.Add("Calibration is not stable against the approved baseline.");
if (snapshot.FalseExitAnnualRate > thresholds.MaximumFalseExitAnnualRate)
blockers.Add("False-exit annual rate exceeds the approved threshold.");
if (snapshot.ReentryCaptureRate < thresholds.MinimumReentryCaptureRate)
blockers.Add("Reentry capture rate is below the approved threshold.");
if (snapshot.Pbo > thresholds.MaximumPbo)
blockers.Add("Probability of backtest overfitting exceeds the approved threshold.");
if (snapshot.Dsr < thresholds.MinimumDsr)
blockers.Add("Deflated Sharpe Ratio is below the approved threshold.");
if (!snapshot.PositiveUnderDoubleCost)
blockers.Add("Expected utility is not positive under the 2x cost stress.");
if (snapshot.ShadowTradingDays < 504)
warnings.Add("Evidence is below the preferred 24-month horizon.");
return new PromotionGateResult(
blockers.Count == 0 ? GateDecision.Pass : GateDecision.Hold,
blockers,
warnings,
RequiresIndependentValidation: true,
RequiresHumanApproval: true,
Boundary: "EVIDENCE_ONLY_NO_AUTO_PROMOTION");
}
}
@@ -0,0 +1,39 @@
namespace KArtSell.Modules.ModelOperations.Domain;
public sealed class ScheduleOccurrencePlanner
{
public DateTimeOffset GetNextDueAt(
DateTimeOffset scheduledFor,
string cadence,
string catchUpPolicy,
int maxCatchUp,
DateTimeOffset now)
{
if (maxCatchUp is < 0 or > 31) throw new ArgumentOutOfRangeException(nameof(maxCatchUp));
var next = Advance(scheduledFor, cadence);
if (catchUpPolicy.Equals("ALL_WITH_LIMIT", StringComparison.OrdinalIgnoreCase))
return next;
if (!catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase)
&& !catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
var guard = 0;
while (next <= now)
{
next = Advance(next, cadence);
if (++guard > 5000) throw new InvalidOperationException("Schedule catch-up exceeded safety guard.");
}
return next;
}
private static DateTimeOffset Advance(DateTimeOffset value, string cadence) => cadence.ToUpperInvariant() switch
{
"DAILY" => value.AddDays(1),
"WEEKLY" => value.AddDays(7),
"MONTHLY" => value.AddMonths(1),
"QUARTERLY" => value.AddMonths(3),
"EVENT_DRIVEN" => value.AddYears(100),
_ => throw new InvalidOperationException($"Unsupported cadence '{cadence}'.")
};
}
@@ -0,0 +1,26 @@
using FastEndpoints;
using KArtSell.Modules.ModelOperations.FeedbackLoop;
namespace KArtSell.Modules.ModelOperations.Features.GetModelFeedbackLoop;
public sealed class Endpoint : EndpointWithoutRequest<Response>
{
public override void Configure()
{
Get("/model-operations/feedback-loop");
Roles("Quant", "Risk", "Compliance", "Operations", "Administrator");
Summary(x =>
{
x.Summary = "Returns the governed continuous model feedback plan.";
x.Description = "This endpoint exposes evaluation/proposal stages only. It cannot activate a model or submit an order.";
});
}
public override Task HandleAsync(CancellationToken ct)
{
var stages = ModelFeedbackPlan.Stages.Select(x => new ModelFeedbackStageResponse(
x.Order, x.StageCode, x.OperationCode, x.Name, x.AutomationBoundary,
x.RequiredEvidence, x.HumanDecision, x.FailureAction)).ToArray();
return Send.OkAsync(new Response(ModelFeedbackPlan.Boundary, stages), ct);
}
}
@@ -0,0 +1,13 @@
namespace KArtSell.Modules.ModelOperations.Features.GetModelFeedbackLoop;
public sealed record ModelFeedbackStageResponse(
int Order,
string StageCode,
string OperationCode,
string Name,
string AutomationBoundary,
string RequiredEvidence,
string HumanDecision,
string FailureAction);
public sealed record Response(string Boundary, IReadOnlyList<ModelFeedbackStageResponse> Stages);
@@ -0,0 +1,35 @@
using FastEndpoints;
using KArtSell.Modules.ModelOperations.Domain;
namespace KArtSell.Modules.ModelOperations.Features.GetModelOperationsPlan;
public sealed class Endpoint : EndpointWithoutRequest<Response>
{
public override void Configure()
{
Get("/internal/v1/model-operations/plan");
Roles("Quant", "Risk", "System", "Auditor");
Description(x => x.WithTags("ModelOperations"));
}
public override Task HandleAsync(CancellationToken ct)
{
var items = ModelOperationRegistry.All.Select(x => new OperationItem(
x.OperationCode,
x.Name,
x.Cadence.ToContractValue(),
x.AutomationMode.ToContractValue(),
x.Queue,
x.PrimaryOwner,
x.SecondaryOwner,
x.RequiredEvidence,
x.Output,
x.Gate)).ToArray();
return Send.OkAsync(new Response(
"RESEARCH_CANDIDATE_NOT_PRODUCTION",
"AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF",
"EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED",
items), ct);
}
}
@@ -0,0 +1,19 @@
namespace KArtSell.Modules.ModelOperations.Features.GetModelOperationsPlan;
public sealed record OperationItem(
string OperationCode,
string Name,
string Cadence,
string AutomationMode,
string Queue,
string PrimaryOwner,
string SecondaryOwner,
string RequiredEvidence,
string Output,
string Gate);
public sealed record Response(
string AlgorithmStatus,
string OrderCapability,
string ModelMutationBoundary,
IReadOnlyList<OperationItem> Operations);
@@ -0,0 +1,94 @@
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
public enum ModelFeedbackCycleState
{
Planned,
PredictionFrozen,
OutcomesMaturing,
Evaluated,
ImprovementProposed,
ChallengerPlanned,
IndependentlyValidated,
PromotionReviewPending,
BusinessHold,
Closed
}
public sealed record ModelFeedbackTransition(
ModelFeedbackCycleState From,
ModelFeedbackCycleState To,
string ReasonCode,
string EvidenceHash,
string ActorId,
DateTimeOffset OccurredAt);
public sealed class ModelFeedbackCycle
{
private static readonly IReadOnlyDictionary<ModelFeedbackCycleState, IReadOnlySet<ModelFeedbackCycleState>> Allowed =
new Dictionary<ModelFeedbackCycleState, IReadOnlySet<ModelFeedbackCycleState>>
{
[ModelFeedbackCycleState.Planned] = Set(ModelFeedbackCycleState.PredictionFrozen, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.PredictionFrozen] = Set(ModelFeedbackCycleState.OutcomesMaturing, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.OutcomesMaturing] = Set(ModelFeedbackCycleState.Evaluated, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.Evaluated] = Set(ModelFeedbackCycleState.ImprovementProposed, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.ImprovementProposed] = Set(ModelFeedbackCycleState.ChallengerPlanned, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.ChallengerPlanned] = Set(ModelFeedbackCycleState.IndependentlyValidated, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.IndependentlyValidated] = Set(ModelFeedbackCycleState.PromotionReviewPending, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.PromotionReviewPending] = Set(ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
[ModelFeedbackCycleState.BusinessHold] = Set(ModelFeedbackCycleState.Planned, ModelFeedbackCycleState.Closed),
[ModelFeedbackCycleState.Closed] = new HashSet<ModelFeedbackCycleState>()
};
private readonly List<ModelFeedbackTransition> transitions = [];
public ModelFeedbackCycle(Guid cycleId, string scopeKey, string baseModelVersion, DateTimeOffset startedAt)
{
if (cycleId == Guid.Empty) throw new ArgumentException("Cycle ID is required.", nameof(cycleId));
if (string.IsNullOrWhiteSpace(scopeKey)) throw new ArgumentException("Scope key is required.", nameof(scopeKey));
if (string.IsNullOrWhiteSpace(baseModelVersion)) throw new ArgumentException("Base model version is required.", nameof(baseModelVersion));
CycleId = cycleId;
ScopeKey = scopeKey;
BaseModelVersion = baseModelVersion;
StartedAt = startedAt;
State = ModelFeedbackCycleState.Planned;
Revision = 1;
LastOccurredAt = startedAt;
}
public Guid CycleId { get; }
public string ScopeKey { get; }
public string BaseModelVersion { get; }
public DateTimeOffset StartedAt { get; }
public ModelFeedbackCycleState State { get; private set; }
public int Revision { get; private set; }
public DateTimeOffset LastOccurredAt { get; private set; }
public DateTimeOffset? ClosedAt { get; private set; }
public IReadOnlyList<ModelFeedbackTransition> Transitions => transitions;
public ModelFeedbackTransition MoveTo(
ModelFeedbackCycleState next,
string reasonCode,
string evidenceHash,
string actorId,
DateTimeOffset occurredAt)
{
if (!Allowed[State].Contains(next))
throw new InvalidOperationException($"Feedback cycle transition {State} -> {next} is not allowed.");
if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("Reason code is required.", nameof(reasonCode));
if (string.IsNullOrWhiteSpace(evidenceHash)) throw new ArgumentException("Evidence hash is required.", nameof(evidenceHash));
if (string.IsNullOrWhiteSpace(actorId)) throw new ArgumentException("Actor ID is required.", nameof(actorId));
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Feedback transition time cannot move backwards.");
var transition = new ModelFeedbackTransition(State, next, reasonCode, evidenceHash, actorId, occurredAt);
transitions.Add(transition);
State = next;
Revision++;
LastOccurredAt = occurredAt;
if (next == ModelFeedbackCycleState.Closed) ClosedAt = occurredAt;
return transition;
}
private static IReadOnlySet<ModelFeedbackCycleState> Set(params ModelFeedbackCycleState[] states)
=> new HashSet<ModelFeedbackCycleState>(states);
}
@@ -0,0 +1,29 @@
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
public sealed record ModelFeedbackStage(
int Order,
string StageCode,
string OperationCode,
string Name,
string AutomationBoundary,
string RequiredEvidence,
string HumanDecision,
string FailureAction);
public static class ModelFeedbackPlan
{
public const string Boundary = "EVALUATION_AND_PROPOSAL_ONLY; NO_AUTOMATIC_MODEL_ACTIVATION; NO_AUTOMATIC_ORDER_SUBMISSION";
public static IReadOnlyList<ModelFeedbackStage> Stages { get; } =
[
new(1, "FREEZE", "J31", "PredictionFreezeRun", "EVALUATION_ONLY", "PIT cutoff, Dataset/Model/Config/Code SHA", "None", "BUSINESS_HOLD"),
new(2, "MATURE", "J32", "OutcomeMaturitySweep", "EVALUATION_ONLY", "1/5/20/63/126/252 trading-session windows", "None", "BUSINESS_HOLD"),
new(3, "SCORE", "J10/J11", "OutcomeEvaluationAndScorecard", "EVALUATION_ONLY", "versioned outcomes, metric definition and cohort", "None", "BUSINESS_HOLD"),
new(4, "DIAGNOSE", "J17/J33/J34", "DriftAttributionCalibrationReview", "EVALUATION_ONLY", "drift, false-exit, gain-capture, reentry and calibration", "None", "BUSINESS_HOLD"),
new(5, "HYPOTHESIS", "J35", "ImprovementHypothesisBuild", "PROPOSAL_ONLY", "supporting and counter evidence, falsification test", "Quant/Risk review", "CLOSE_OR_HOLD"),
new(6, "CHALLENGER", "J36", "ChallengerExperimentPlan", "PROPOSAL_ONLY", "frozen registry, OOS plan, cost stress and stop criteria", "Independent validation approval", "CLOSE_OR_HOLD"),
new(7, "VALIDATE", "J19/J20/J37", "FrozenOosAndIndependentValidation", "EVALUATION_AND_PROPOSAL_ONLY", "reproducibility, PBO, DSR, stability, double-cost", "Independent validator decision", "REJECT_OR_HOLD"),
new(8, "REVIEW", "J22", "PromotionEvidenceReviewBuild", "PROPOSAL_ONLY", "complete gate pack, rollback and operational evidence", "Maker-checker investment/risk/compliance approval", "REJECT_OR_EXPIRE"),
new(9, "ACTIVATE", "MANUAL_ONLY", "ControlledModelActivation", "AUTOMATION_FORBIDDEN", "approved effective_at, capability gate and rollback", "Separate human change approval", "KEEP_CURRENT_CHAMPION")
];
}
@@ -0,0 +1,47 @@
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
public enum EvidenceClassification
{
Source,
Assumption,
Unknown,
DecisionRequired
}
public sealed record HypothesisEvidence(
EvidenceClassification Classification,
string Reference,
string Statement,
string ContentHash);
public sealed record ModelImprovementHypothesis(
Guid HypothesisId,
string ScopeKey,
string BaseModelVersion,
string ProblemStatement,
string ProposedChange,
string FalsificationCriterion,
IReadOnlyList<HypothesisEvidence> SupportingEvidence,
IReadOnlyList<HypothesisEvidence> CounterEvidence,
string Owner,
DateTimeOffset ExpiresAt)
{
public IReadOnlyList<string> Validate(DateTimeOffset now)
{
var errors = new List<string>();
if (HypothesisId == Guid.Empty) errors.Add("HYPOTHESIS_ID_REQUIRED");
if (string.IsNullOrWhiteSpace(ScopeKey)) errors.Add("SCOPE_REQUIRED");
if (string.IsNullOrWhiteSpace(BaseModelVersion)) errors.Add("BASE_MODEL_REQUIRED");
if (string.IsNullOrWhiteSpace(ProblemStatement)) errors.Add("PROBLEM_REQUIRED");
if (string.IsNullOrWhiteSpace(ProposedChange)) errors.Add("CHANGE_REQUIRED");
if (string.IsNullOrWhiteSpace(FalsificationCriterion)) errors.Add("FALSIFICATION_TEST_REQUIRED");
if (SupportingEvidence.Count == 0) errors.Add("SUPPORTING_EVIDENCE_REQUIRED");
if (CounterEvidence.Count == 0) errors.Add("COUNTER_EVIDENCE_REQUIRED");
if (SupportingEvidence.Concat(CounterEvidence).Any(x => x.Classification == EvidenceClassification.Unknown))
errors.Add("UNKNOWN_EVIDENCE_MUST_BE_RESOLVED_OR_DOWNGRADED");
if (SupportingEvidence.Concat(CounterEvidence).Any(x => x.Classification == EvidenceClassification.DecisionRequired))
errors.Add("DECISION_REQUIRED_BLOCKS_EXPERIMENT");
if (ExpiresAt <= now) errors.Add("HYPOTHESIS_EXPIRED");
return errors;
}
}
@@ -0,0 +1,68 @@
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Versioning;
using KArtSell.Modules.ModelOperations.Application;
namespace KArtSell.Modules.ModelOperations.Infrastructure;
public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connectionFactory) : IApprovedModelContextReader
{
private const string Sql = """
select mv.scope_key as ScopeKey,
mv.model_version as ModelVersion,
mv.config_version as ConfigVersion,
mv.code_sha as CodeSha,
mv.contract_version as ContractVersion,
mv.lifecycle_state as LifecycleState,
mv.effective_at as EffectiveAt,
dm.dataset_id as DatasetId,
dm.content_hash as DataHash
from governance.model_version_registry mv
join lateral (
select dataset_id, content_hash
from evaluation.dataset_manifest
where scope_key = mv.scope_key
and status = 'APPROVED'
and frozen_at <= @AsOf
order by frozen_at desc
limit 1
) dm on true
where mv.scope_key = @ScopeKey
and mv.lifecycle_state in ('RESEARCH', 'CHALLENGER', 'SHADOW', 'CANDIDATE', 'APPROVED')
and mv.effective_at <= @AsOf
order by mv.effective_at desc
limit 1;
""";
public async Task<ApprovedModelContext?> ReadAsync(
string scopeKey,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<Row>(new CommandDefinition(
Sql,
new { ScopeKey = scopeKey, AsOf = asOf },
cancellationToken: cancellationToken));
if (row is null)
return null;
return new ApprovedModelContext(
row.ScopeKey,
new VersionSet(row.DatasetId, row.DataHash, row.ModelVersion, row.ConfigVersion, row.CodeSha, row.ContractVersion),
row.LifecycleState,
row.EffectiveAt);
}
private sealed record Row(
string ScopeKey,
string DatasetId,
string DataHash,
string ModelVersion,
string ConfigVersion,
string CodeSha,
string ContractVersion,
string LifecycleState,
DateTimeOffset EffectiveAt);
}
@@ -0,0 +1,103 @@
using System.Text.Json;
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Hashing;
using KArtSell.BuildingBlocks.Reliability;
using KArtSell.Modules.ModelOperations.Application;
namespace KArtSell.Modules.ModelOperations.Infrastructure;
public sealed class DapperModelOperationRequestRepository(
IDbConnectionFactory connectionFactory,
IOutboxWriter outboxWriter) : IModelOperationRequestRepository
{
private const string InsertSql = """
insert into evaluation.model_operation_request
(request_id, schedule_id, operation_code, scope_key, automation_mode, idempotency_key,
dataset_id, data_hash, model_version, config_version, code_sha, contract_version,
lifecycle_state, correlation_id, status, requested_at)
values
(@RequestId, @ScheduleId, @OperationCode, @ScopeKey, @AutomationMode, @IdempotencyKey,
@DatasetId, @DataHash, @ModelVersion, @ConfigVersion, @CodeSha, @ContractVersion,
@LifecycleState, @CorrelationId, 'REQUESTED', @RequestedAt)
on conflict (idempotency_key) do nothing;
""";
private const string InsertStatusEventSql = """
insert into evaluation.model_operation_status_event
(event_id, request_id, from_status, to_status, reason_code, payload_hash,
actor_type, correlation_id, occurred_at)
values
(@EventId, @RequestId, null, 'REQUESTED', null, @PayloadHash,
'SYSTEM', @CorrelationId, @OccurredAt);
""";
public async Task<bool> AddAsync(ModelOperationRequest request, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(
InsertSql,
new
{
request.RequestId,
request.ScheduleId,
request.OperationCode,
request.ScopeKey,
request.AutomationMode,
request.IdempotencyKey,
request.Context.VersionSet.DatasetId,
request.Context.VersionSet.DataHash,
request.Context.VersionSet.ModelVersion,
request.Context.VersionSet.ConfigVersion,
request.Context.VersionSet.CodeSha,
request.Context.VersionSet.ContractVersion,
request.Context.LifecycleState,
request.CorrelationId,
request.RequestedAt
},
transaction,
cancellationToken: cancellationToken));
if (affected == 1)
{
var payload = JsonSerializer.Serialize(new
{
requestId = request.RequestId,
request.OperationCode,
request.ScopeKey,
request.AutomationMode,
versionSet = request.Context.VersionSet,
boundary = "NO_AUTO_MODEL_MUTATION"
});
var payloadHash = ContentHasher.Sha256(payload);
await connection.ExecuteAsync(new CommandDefinition(
InsertStatusEventSql,
new
{
EventId = Guid.NewGuid(),
request.RequestId,
PayloadHash = payloadHash,
request.CorrelationId,
OccurredAt = request.RequestedAt
},
transaction,
cancellationToken: cancellationToken));
var message = new OutboxMessage(
request.RequestId,
"ModelOperationRequested",
1,
payload,
request.CorrelationId,
request.RequestedAt,
payloadHash);
await outboxWriter.AddAsync(connection, transaction, message, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return affected == 1;
}
}
@@ -0,0 +1,81 @@
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.Modules.ModelOperations.Application;
namespace KArtSell.Modules.ModelOperations.Infrastructure;
public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectionFactory) : IModelScheduleRepository
{
private const string AcquireSql = """
with due as (
select schedule_id
from evaluation.model_operation_schedule
where enabled = true
and next_due_at <= @Now
and (lease_until is null or lease_until < @Now)
order by next_due_at, operation_code, scope_key
for update skip locked
limit @Limit
)
update evaluation.model_operation_schedule s
set lease_owner = @LeaseOwner,
lease_until = @LeaseUntil,
dispatch_revision = dispatch_revision + 1,
updated_at = @Now
from due
where s.schedule_id = due.schedule_id
returning s.schedule_id as ScheduleId,
s.operation_code as OperationCode,
s.scope_key as ScopeKey,
s.cadence as Cadence,
s.automation_mode as AutomationMode,
s.queue_name as Queue,
concat(s.operation_code, ':', s.scope_key, ':', s.schedule_version, ':', to_char(s.next_due_at at time zone 'UTC', 'YYYYMMDDHH24MISS')) as IdempotencyKey,
s.schedule_version as ScheduleVersion,
s.next_due_at as ScheduledFor,
s.catch_up_policy as CatchUpPolicy,
s.max_catch_up as MaxCatchUp;
""";
private const string DispatchedSql = """
update evaluation.model_operation_schedule
set last_dispatched_at = @DispatchedAt,
last_background_job_id = @BackgroundJobId,
next_due_at = @NextDueAt,
lease_owner = null,
lease_until = null,
last_error_code = null,
updated_at = @DispatchedAt
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
""";
private const string ReleaseSql = """
update evaluation.model_operation_schedule
set lease_owner = null,
lease_until = null,
last_error_code = @ReasonCode,
next_due_at = greatest(next_due_at, @ReleasedAt) + interval '1 hour',
updated_at = @ReleasedAt
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
""";
public async Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var items = await connection.QueryAsync<DueModelOperation>(new CommandDefinition(AcquireSql, new { Now = now, LeaseOwner = leaseOwner, LeaseUntil = now.Add(leaseDuration), Limit = limit }, cancellationToken: cancellationToken));
return items.AsList();
}
public async Task MarkDispatchedAsync(Guid scheduleId, string leaseOwner, string backgroundJobId, DateTimeOffset dispatchedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(DispatchedSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, BackgroundJobId = backgroundJobId, DispatchedAt = dispatchedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before dispatch completion.");
}
public async Task ReleaseAsync(Guid scheduleId, string leaseOwner, string reasonCode, DateTimeOffset releasedAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(ReleaseSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ReasonCode = reasonCode, ReleasedAt = releasedAt }, cancellationToken: cancellationToken));
}
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
<PackageReference Include="FastEndpoints" />
<PackageReference Include="Dapper" />
<PackageReference Include="Hangfire.Core" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
using KArtSell.Modules.ModelOperations.Application;
using KArtSell.Modules.ModelOperations.Domain;
using KArtSell.Modules.ModelOperations.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace KArtSell.Modules.ModelOperations;
public static class ModelOperationsModule
{
public static IServiceCollection AddModelOperationsModule(this IServiceCollection services)
{
services.AddSingleton<PromotionGateEvaluator>();
services.AddSingleton<ScheduleOccurrencePlanner>();
services.AddSingleton<EvaluationWindowPlanner>();
services.AddScoped<IApprovedModelContextReader, DapperApprovedModelContextReader>();
services.AddScoped<IModelScheduleRepository, DapperModelScheduleRepository>();
services.AddScoped<IModelOperationRequestRepository, DapperModelOperationRequestRepository>();
services.AddScoped<IModelOperationRequestService, ModelOperationRequestService>();
return services;
}
}
@@ -0,0 +1,60 @@
using Hangfire;
using Hangfire.Common;
using Hangfire.States;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.ModelOperations.Application;
using KArtSell.Modules.ModelOperations.Domain;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.Scheduling;
public sealed class ModelOperationsDispatcherJob(
IModelScheduleRepository schedules,
IBackgroundJobClient jobs,
IClock clock,
ScheduleOccurrencePlanner occurrencePlanner,
ILogger<ModelOperationsDispatcherJob> logger)
{
[Queue("q-control")]
[DisableConcurrentExecution(timeoutInSeconds: 840)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ExecuteAsync()
{
var now = clock.UtcNow;
var leaseOwner = $"dispatcher:{Environment.MachineName}:{Guid.NewGuid():N}";
var due = await schedules.AcquireDueAsync(now, leaseOwner, TimeSpan.FromMinutes(14), 50, CancellationToken.None);
foreach (var item in due)
{
try
{
var job = Job.FromExpression<ScheduledModelOperationJob>(handler => handler.ExecuteAsync(
item.ScheduleId,
item.OperationCode,
item.ScopeKey,
item.AutomationMode,
item.IdempotencyKey));
var backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
var nextDueAt = occurrencePlanner.GetNextDueAt(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
await schedules.MarkDispatchedAsync(
item.ScheduleId,
leaseOwner,
backgroundJobId,
now,
nextDueAt,
CancellationToken.None);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to dispatch model operation {OperationCode} for {ScopeKey}.", item.OperationCode, item.ScopeKey);
await schedules.ReleaseAsync(
item.ScheduleId,
leaseOwner,
"DISPATCH_FAILED",
now,
CancellationToken.None);
}
}
}
}
@@ -0,0 +1,30 @@
using Hangfire;
using Microsoft.Extensions.DependencyInjection;
namespace KArtSell.Modules.ModelOperations.Scheduling;
public static class ModelOperationsScheduler
{
public const string DispatcherJobId = "model-operations-dispatcher-v1";
public static void RegisterModelOperationsSchedules(
this IServiceProvider services,
bool dispatcherEnabled,
string dispatcherCron)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dispatcherCron);
var manager = services.GetRequiredService<IRecurringJobManager>();
if (!dispatcherEnabled)
{
manager.RemoveIfExists(DispatcherJobId);
return;
}
manager.AddOrUpdate<ModelOperationsDispatcherJob>(
DispatcherJobId,
job => job.ExecuteAsync(),
dispatcherCron,
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
}
@@ -0,0 +1,45 @@
using Hangfire;
using KArtSell.Modules.ModelOperations.Application;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.Scheduling;
public sealed class ScheduledModelOperationJob(
IModelOperationRequestService service,
ILogger<ScheduledModelOperationJob> logger)
{
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task ExecuteAsync(
Guid scheduleId,
string operationCode,
string scopeKey,
string automationMode,
string idempotencyKey)
{
var correlationId = $"model-operation:{operationCode}:{Guid.NewGuid():N}";
var request = await service.RequestAsync(
scheduleId,
operationCode,
scopeKey,
automationMode,
idempotencyKey,
correlationId,
CancellationToken.None);
if (request is null)
{
logger.LogWarning(
"Model operation {OperationCode} for {ScopeKey} was not created because the approved frozen context is unavailable or the idempotency key already exists.",
operationCode,
scopeKey);
return;
}
logger.LogInformation(
"Requested model operation {OperationCode} for {ScopeKey} with model {ModelVersion} and dataset {DatasetId}. No model mutation is performed by this job.",
request.OperationCode,
request.ScopeKey,
request.Context.VersionSet.ModelVersion,
request.Context.VersionSet.DatasetId);
}
}
@@ -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;
}
}