From f1ec1a3ee1382975ee132d796c84133c7916333e Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 25 Jul 2026 11:25:33 +0900 Subject: [PATCH] feat(wbs-ux): implement real-world CRUD templates and refactor QuantDataGrid to AG Grid --- .../BulkInsertMarketExcelEndpoint.cs | 88 ++++++++++ .../ExportStreamingFactorOlapEndpoint.cs | 64 ++++++++ .../UpdateFactorThresholdEndpoint.cs | 61 +++++++ .../QuantEngine.Web/QuantEngine.Web.csproj | 2 + src/frontend/src/components/QuantDataGrid.vue | 123 ++++++++------ .../templates/AdvancedAgGridMarketLayout.vue | 132 +++++++++++++++ .../templates/FactorParamDetailLayout.vue | 113 +++++++++++++ .../views/templates/RealDashboardLayout.vue | 42 +++++ .../views/templates/RealExcelUploadMapper.vue | 153 ++++++++++++++++++ .../templates/RealMakerCheckerLayout.vue | 28 ++++ .../views/templates/RealOlapExportLayout.vue | 17 ++ .../views/templates/RealRollbackLayout.vue | 32 ++++ .../templates/RebalancePipelineLayout.vue | 39 +++++ .../templates/WaterfallShadowTreeLayout.vue | 40 +++++ 14 files changed, 882 insertions(+), 52 deletions(-) create mode 100644 src/dotnet/QuantEngine.Web/Endpoints/BulkInsertMarketExcelEndpoint.cs create mode 100644 src/dotnet/QuantEngine.Web/Endpoints/ExportStreamingFactorOlapEndpoint.cs create mode 100644 src/dotnet/QuantEngine.Web/Endpoints/UpdateFactorThresholdEndpoint.cs create mode 100644 src/frontend/src/views/templates/AdvancedAgGridMarketLayout.vue create mode 100644 src/frontend/src/views/templates/FactorParamDetailLayout.vue create mode 100644 src/frontend/src/views/templates/RealDashboardLayout.vue create mode 100644 src/frontend/src/views/templates/RealExcelUploadMapper.vue create mode 100644 src/frontend/src/views/templates/RealMakerCheckerLayout.vue create mode 100644 src/frontend/src/views/templates/RealOlapExportLayout.vue create mode 100644 src/frontend/src/views/templates/RealRollbackLayout.vue create mode 100644 src/frontend/src/views/templates/RebalancePipelineLayout.vue create mode 100644 src/frontend/src/views/templates/WaterfallShadowTreeLayout.vue diff --git a/src/dotnet/QuantEngine.Web/Endpoints/BulkInsertMarketExcelEndpoint.cs b/src/dotnet/QuantEngine.Web/Endpoints/BulkInsertMarketExcelEndpoint.cs new file mode 100644 index 00000000..f8c282ef --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Endpoints/BulkInsertMarketExcelEndpoint.cs @@ -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; + +/// +/// 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. +/// +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); + } +} diff --git a/src/dotnet/QuantEngine.Web/Endpoints/ExportStreamingFactorOlapEndpoint.cs b/src/dotnet/QuantEngine.Web/Endpoints/ExportStreamingFactorOlapEndpoint.cs new file mode 100644 index 00000000..2ef0bbf5 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Endpoints/ExportStreamingFactorOlapEndpoint.cs @@ -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; + +/// +/// BFF FastEndpoints for downloading large factor output data using sequential data reader streams. +/// SOLID: Single Responsibility for streaming CSV reports. +/// +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(); + } +} diff --git a/src/dotnet/QuantEngine.Web/Endpoints/UpdateFactorThresholdEndpoint.cs b/src/dotnet/QuantEngine.Web/Endpoints/UpdateFactorThresholdEndpoint.cs new file mode 100644 index 00000000..5bfe573c --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Endpoints/UpdateFactorThresholdEndpoint.cs @@ -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); + +/// +/// BFF FastEndpoints for updating factor threshold parameter logic. +/// SOLID: Single Responsibility for updating factor settings. +/// +public class UpdateFactorThresholdEndpoint : Endpoint +{ + 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); + } + } +} diff --git a/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj b/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj index a12a0985..5df0391d 100644 --- a/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj +++ b/src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj @@ -7,6 +7,8 @@ + + diff --git a/src/frontend/src/components/QuantDataGrid.vue b/src/frontend/src/components/QuantDataGrid.vue index e593a301..e250ad79 100644 --- a/src/frontend/src/components/QuantDataGrid.vue +++ b/src/frontend/src/components/QuantDataGrid.vue @@ -1,71 +1,90 @@ + + diff --git a/src/frontend/src/views/templates/AdvancedAgGridMarketLayout.vue b/src/frontend/src/views/templates/AdvancedAgGridMarketLayout.vue new file mode 100644 index 00000000..ce18e30c --- /dev/null +++ b/src/frontend/src/views/templates/AdvancedAgGridMarketLayout.vue @@ -0,0 +1,132 @@ + + + + + + diff --git a/src/frontend/src/views/templates/FactorParamDetailLayout.vue b/src/frontend/src/views/templates/FactorParamDetailLayout.vue new file mode 100644 index 00000000..39b1cacf --- /dev/null +++ b/src/frontend/src/views/templates/FactorParamDetailLayout.vue @@ -0,0 +1,113 @@ + + + + diff --git a/src/frontend/src/views/templates/RealDashboardLayout.vue b/src/frontend/src/views/templates/RealDashboardLayout.vue new file mode 100644 index 00000000..0ba1f94b --- /dev/null +++ b/src/frontend/src/views/templates/RealDashboardLayout.vue @@ -0,0 +1,42 @@ + + + + diff --git a/src/frontend/src/views/templates/RealExcelUploadMapper.vue b/src/frontend/src/views/templates/RealExcelUploadMapper.vue new file mode 100644 index 00000000..ebffffdc --- /dev/null +++ b/src/frontend/src/views/templates/RealExcelUploadMapper.vue @@ -0,0 +1,153 @@ + + + + diff --git a/src/frontend/src/views/templates/RealMakerCheckerLayout.vue b/src/frontend/src/views/templates/RealMakerCheckerLayout.vue new file mode 100644 index 00000000..5b2c5489 --- /dev/null +++ b/src/frontend/src/views/templates/RealMakerCheckerLayout.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/frontend/src/views/templates/RealOlapExportLayout.vue b/src/frontend/src/views/templates/RealOlapExportLayout.vue new file mode 100644 index 00000000..4e16814a --- /dev/null +++ b/src/frontend/src/views/templates/RealOlapExportLayout.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/frontend/src/views/templates/RealRollbackLayout.vue b/src/frontend/src/views/templates/RealRollbackLayout.vue new file mode 100644 index 00000000..33b9c791 --- /dev/null +++ b/src/frontend/src/views/templates/RealRollbackLayout.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/frontend/src/views/templates/RebalancePipelineLayout.vue b/src/frontend/src/views/templates/RebalancePipelineLayout.vue new file mode 100644 index 00000000..5b7502ea --- /dev/null +++ b/src/frontend/src/views/templates/RebalancePipelineLayout.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/frontend/src/views/templates/WaterfallShadowTreeLayout.vue b/src/frontend/src/views/templates/WaterfallShadowTreeLayout.vue new file mode 100644 index 00000000..5ba6d755 --- /dev/null +++ b/src/frontend/src/views/templates/WaterfallShadowTreeLayout.vue @@ -0,0 +1,40 @@ + + +