feat(wbs-ux): implement real-world CRUD templates and refactor QuantDataGrid to AG Grid

This commit is contained in:
2026-07-25 11:25:33 +09:00
parent 7d62cc44c6
commit f1ec1a3ee1
14 changed files with 882 additions and 52 deletions
@@ -0,0 +1,88 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FastEndpoints;
using Npgsql;
using ExcelDataReader;
using Microsoft.Extensions.Configuration;
namespace QuantEngine.Web.Endpoints;
/// <summary>
/// BFF FastEndpoints for streaming Excel file upload and importing to PostgreSQL using COPY binary protocol.
/// SOLID: Single Responsibility for streaming large files to prevent OOM.
/// </summary>
public class BulkInsertMarketExcelEndpoint : EndpointWithoutRequest
{
private readonly string _connectionString;
public BulkInsertMarketExcelEndpoint(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")
?? throw new ArgumentNullException("ConnectionStrings__DefaultConnection");
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
}
public override void Configure()
{
Post("/api/admin/market/upload-excel-stream");
AllowFileUploads();
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
if (Files.Count == 0)
{
await SendAsync(new { success = false, message = "업로드된 파일이 없습니다." }, 400, ct);
return;
}
var file = Files[0];
using var fileStream = file.OpenReadStream();
using var reader = ExcelReaderFactory.CreateReader(fileStream);
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
using var writer = await conn.BeginBinaryImportAsync(
"COPY quantengine.market_raw_history (ticker, as_of_date, close_price, nav_price, disparate_ratio, raw_payload, provenance) FROM STDIN (FORMAT BINARY)",
ct
);
bool isHeader = true;
int processedRows = 0;
while (reader.Read())
{
if (isHeader)
{
isHeader = false;
continue;
}
string ticker = reader.GetValue(0)?.ToString() ?? string.Empty;
string asOfDate = reader.GetValue(1)?.ToString() ?? string.Empty;
decimal closePrice = Convert.ToDecimal(reader.GetValue(2) ?? 0);
decimal navPrice = Convert.ToDecimal(reader.GetValue(3) ?? 0);
decimal disparateRatio = navPrice > 0 ? (closePrice - navPrice) / navPrice : 0;
if (string.IsNullOrWhiteSpace(ticker) || ticker.Length < 6) continue;
await writer.StartRowAsync(ct);
await writer.WriteAsync(ticker, ct);
await writer.WriteAsync(asOfDate, ct);
await writer.WriteAsync(closePrice, ct);
await writer.WriteAsync(navPrice, ct);
await writer.WriteAsync(disparateRatio, ct);
await writer.WriteAsync("{}", ct);
await writer.WriteAsync("{\"source\": \"excel_stream_uploader\"}", ct);
processedRows++;
}
await writer.CompleteAsync(ct);
await SendAsync(new { success = true, count = processedRows, message = "성공적으로 스트리밍 적재 완료되었습니다." }, cancellation: ct);
}
}
@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FastEndpoints;
using Npgsql;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Http;
namespace QuantEngine.Web.Endpoints;
/// <summary>
/// BFF FastEndpoints for downloading large factor output data using sequential data reader streams.
/// SOLID: Single Responsibility for streaming CSV reports.
/// </summary>
public class ExportStreamingFactorOlapEndpoint : EndpointWithoutRequest
{
private readonly string _connectionString;
public ExportStreamingFactorOlapEndpoint(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")
?? throw new ArgumentNullException("ConnectionStrings__DefaultConnection");
}
public override void Configure()
{
Get("/api/admin/reports/export-factor-olap-stream");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
HttpContext.Response.ContentType = "text/csv";
// ASP0019 대응: Headers.Append 또는 인덱서 사용
HttpContext.Response.Headers.Append("Content-Disposition", $"attachment; filename=Streaming_Factor_Report_{DateTime.Now:yyyyMMdd}.csv");
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = new NpgsqlCommand(@"
SELECT ticker, as_of_date, factor_id, score, calculation_state
FROM quantengine.factor_output_history
ORDER BY as_of_date DESC;", conn);
using var reader = await cmd.ExecuteReaderAsync(System.Data.CommandBehavior.SequentialAccess, ct);
using var writer = new StreamWriter(HttpContext.Response.Body, System.Text.Encoding.UTF8);
await writer.WriteLineAsync("Ticker,AsOfDate,FactorId,Score,State");
while (await reader.ReadAsync(ct))
{
string ticker = reader.GetString(0);
string asOfDate = reader.GetString(1);
string factorId = reader.GetString(2);
decimal score = reader.GetDecimal(3);
string state = reader.GetString(4);
await writer.WriteLineAsync($"{ticker},{asOfDate},{factorId},{score},{state}");
}
await writer.FlushAsync();
}
}
@@ -0,0 +1,61 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FastEndpoints;
using Dapper;
using Npgsql;
using Microsoft.Extensions.Configuration;
namespace QuantEngine.Web.Endpoints;
public record UpdateThresholdRequest(string FactorId, string CalibrationState, string ThresholdParamsJson);
public record UpdateThresholdResponse(bool Success, string Message);
/// <summary>
/// BFF FastEndpoints for updating factor threshold parameter logic.
/// SOLID: Single Responsibility for updating factor settings.
/// </summary>
public class UpdateFactorThresholdEndpoint : Endpoint<UpdateThresholdRequest, UpdateThresholdResponse>
{
private readonly string _connectionString;
public UpdateFactorThresholdEndpoint(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")
?? throw new ArgumentNullException("ConnectionStrings__DefaultConnection");
}
public override void Configure()
{
Post("/api/admin/factors/update-threshold");
AllowAnonymous();
}
public override async Task HandleAsync(UpdateThresholdRequest req, CancellationToken ct)
{
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
const string sql = @"
UPDATE quantengine.factor_version_history
SET calibration_state = @CalibrationState,
threshold_params = @ThresholdParams::jsonb,
updated_at = NOW()
WHERE factor_id = @FactorId;";
int affectedRows = await conn.ExecuteAsync(sql, new {
req.FactorId,
req.CalibrationState,
ThresholdParams = req.ThresholdParamsJson
});
if (affectedRows > 0)
{
await SendAsync(new UpdateThresholdResponse(true, "성공적으로 반영되었습니다."), cancellation: ct);
}
else
{
await SendAsync(new UpdateThresholdResponse(false, "해당 Factor ID를 찾을 수 없습니다."), statusCode: 404, cancellation: ct);
}
}
}
@@ -7,6 +7,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="EPPlus" Version="8.6.3" />
<PackageReference Include="ExcelDataReader" Version="3.9.0" />
<PackageReference Include="FastEndpoints" Version="5.34.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageReference Include="Hangfire.Core" Version="1.8.23" />