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