From a03a437f1a868cb2dd48c32f15e636d276954c40 Mon Sep 17 00:00:00 2001 From: SmartRootsSrl Date: Tue, 21 Jul 2026 10:25:36 +0200 Subject: [PATCH] docs: add implementation plan for standalone underlying chart endpoint Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-21-underlying-only-chart.md | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-underlying-only-chart.md diff --git a/docs/superpowers/plans/2026-07-21-underlying-only-chart.md b/docs/superpowers/plans/2026-07-21-underlying-only-chart.md new file mode 100644 index 0000000..4f65bbf --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-underlying-only-chart.md @@ -0,0 +1,577 @@ +# Grafico Standalone Sottostante (senza certificato) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Aggiungere l'endpoint `GET /api/chart/underlying/{idUnderlyings}` che genera un grafico con solo la linea prezzo storico di un sottostante, senza alcun contesto certificato (no strike/barriere/legenda). + +**Architecture:** Stessa pipeline di `/api/chart/v2/{isin}` ma senza certificato: nuova SP `cedlab_Chart_UnderlyingOnly`, nuovo servizio dati `UnderlyingChartDataService`, nuovo metodo statico `RenderSingleSeriesToPng` in `SkiaChartRendererV2` (riusa i suoi helper privati per griglia/assi), nuova route in `ChartController` esistente che riusa `WrapPngInPdf` già presente. + +**Tech Stack:** ASP.NET Core 8, `Microsoft.Data.SqlClient`, SkiaSharp. + +Spec di riferimento: `docs/superpowers/specs/2026-07-21-underlying-only-chart-design.md`. + +--- + +## Task 1: Stored Procedure `cedlab_Chart_UnderlyingOnly` + +**Files:** +- Create: `docs/sql/cedlab_Chart_UnderlyingOnly.sql` + +- [ ] **Step 1: Scrivere lo script SQL** + +```sql +USE [FirstSolutionDB] +GO + +-- ============================================================ +-- SP: cedlab_Chart_UnderlyingOnly +-- Restituisce lo storico prezzi di UN SOLO sottostante, senza +-- alcun contesto certificato (nessuno strike/barriera/trigger). +-- Usata da UnderlyingChartDataService per il grafico standalone +-- sottostante. +-- +-- Output (Px_date ASC, TOP 350 più recenti): +-- Nome VARCHAR -- "{Name} ({Ticker_bbg})", uguale su ogni riga +-- Px_date DATE +-- Px DECIMAL -- Px_close o Px_closeadj in base ad AdjustedPrices +-- ============================================================ + +CREATE OR ALTER PROCEDURE [dbo].[cedlab_Chart_UnderlyingOnly] + @IDUnderlyings INT +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @AdjustedPrices BIT; + SELECT @AdjustedPrices = ISNULL(AdjustedPrices, 0) + FROM dbo.Underlyings + WHERE IDUnderlyings = @IDUnderlyings + AND deleted = 0 + AND sospeso = 0; + + IF @AdjustedPrices IS NULL RETURN; -- non trovato / cancellato / sospeso + + ;WITH Ranked AS + ( + SELECT + p.Px_date, + CASE WHEN @AdjustedPrices = 0 THEN p.Px_close ELSE p.Px_closeadj END AS Px, + ROW_NUMBER() OVER (ORDER BY p.Px_date DESC) AS rn + FROM dbo.Prices p + WHERE p.UnderlyingsID = @IDUnderlyings + AND (p.Px_low IS NOT NULL OR p.Px_high IS NOT NULL OR + p.Px_open IS NOT NULL OR p.Px_close IS NOT NULL) + ) + SELECT + u.Name + ' (' + u.Ticker_bbg + ')' AS Nome, + r.Px_date, + r.Px + FROM Ranked r + CROSS JOIN (SELECT TOP 1 * FROM dbo.Underlyings WHERE IDUnderlyings = @IDUnderlyings) u + WHERE r.rn <= 350 + ORDER BY r.Px_date ASC; +END +GO +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/sql/cedlab_Chart_UnderlyingOnly.sql +git commit -m "docs: add cedlab_Chart_UnderlyingOnly SP script" +``` + +> **Nota per l'utente**: questo script va eseguito manualmente sul DB `FirstSolutionDB` prima di testare l'endpoint (Task 5). Nessun task successivo lo esegue automaticamente. + +--- + +## Task 2: Modelli dati + +**Files:** +- Create: `CertReports.Syncfusion/Models/UnderlyingChartModels.cs` + +- [ ] **Step 1: Scrivere il file dei modelli** + +```csharp +namespace CertReports.Syncfusion.Models; + +/// +/// Singolo punto prezzo per il grafico standalone sottostante +/// (da SP cedlab_Chart_UnderlyingOnly). +/// +public class UnderlyingChartPoint +{ + public DateTime Date { get; set; } + public decimal Px { get; set; } +} + +/// +/// Dati completi per il grafico standalone di un sottostante, +/// senza alcun contesto certificato. +/// +public class UnderlyingChartData +{ + public int IDUnderlyings { get; set; } + + /// Titolo del grafico: "{Name} ({Ticker_bbg})". + public string Nome { get; set; } = string.Empty; + + public List Points { get; set; } = new(); +} +``` + +- [ ] **Step 2: Build per verificare che compili** + +Run: `dotnet build CertReports.Syncfusion` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Models/UnderlyingChartModels.cs +git commit -m "feat: add UnderlyingChartData model" +``` + +--- + +## Task 3: Servizio dati `UnderlyingChartDataService` + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/UnderlyingChartDataService.cs` +- Modify: `CertReports.Syncfusion/Program.cs` + +- [ ] **Step 1: Scrivere il servizio** + +```csharp +using CertReports.Syncfusion.Models; +using Microsoft.Data.SqlClient; +using System.Data; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// Recupera i dati per il grafico standalone di un singolo sottostante +/// (nessun contesto certificato). SP utilizzata: cedlab_Chart_UnderlyingOnly. +/// +public interface IUnderlyingChartDataService +{ + Task GetChartDataAsync(int idUnderlyings); +} + +public class UnderlyingChartDataService : IUnderlyingChartDataService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public UnderlyingChartDataService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("CertDb") + ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); + _logger = logger; + } + + public async Task GetChartDataAsync(int idUnderlyings) + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + await using var cmd = new SqlCommand("cedlab_Chart_UnderlyingOnly", conn) + { CommandType = CommandType.StoredProcedure }; + cmd.Parameters.AddWithValue("@IDUnderlyings", idUnderlyings); + + var result = new UnderlyingChartData { IDUnderlyings = idUnderlyings }; + + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (result.Points.Count == 0) + result.Nome = ToStr(r, "Nome"); + + result.Points.Add(new UnderlyingChartPoint + { + Date = r.GetDateTime(r.GetOrdinal("Px_date")), + Px = Convert.ToDecimal(r.GetValue(r.GetOrdinal("Px"))), + }); + } + + if (result.Points.Count == 0) + { + _logger.LogWarning( + "Nessun prezzo trovato per il sottostante {IDUnderlyings} (non trovato, cancellato, sospeso, o senza storico prezzi)", + idUnderlyings); + return null; + } + + _logger.LogInformation( + "Dati grafico sottostante caricati per {IDUnderlyings}: {Points} punti", + idUnderlyings, result.Points.Count); + + return result; + } + + private static string ToStr(SqlDataReader r, string column) + { + int ord = r.GetOrdinal(column); + return r.IsDBNull(ord) ? string.Empty : r.GetString(ord); + } +} +``` + +- [ ] **Step 2: Registrare il servizio in Program.cs** + +In `CertReports.Syncfusion/Program.cs`, subito dopo la riga 37 (`builder.Services.AddScoped();`): + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 3: Build per verificare che compili** + +Run: `dotnet build CertReports.Syncfusion` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 4: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/UnderlyingChartDataService.cs CertReports.Syncfusion/Program.cs +git commit -m "feat: add UnderlyingChartDataService and DI registration" +``` + +--- + +## Task 4: Renderer — `RenderSingleSeriesToPng` in `SkiaChartRendererV2` + +**Files:** +- Modify: `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` + +Il file espone già come `private static` tutti gli helper necessari (`CreateFont`, `DrawGrid`, `DateToX`, `ValueToY`, `XAxisIntervalMonths`, `XAxisStart`). Il nuovo metodo pubblico va aggiunto nella stessa classe per poterli riusare senza cambiarne la visibilità. + +- [ ] **Step 1: Aggiungere il metodo `RenderSingleSeriesToPng`** + +Inserire subito dopo la chiusura del metodo `RenderToPng` esistente (dopo la riga `return imgData.ToArray();` e la relativa `}` di chiusura, prima del blocco `// Titolo`): + +```csharp + // ═══════════════════════════════════════════════════════════════════ + // Entry point — grafico standalone singolo sottostante (no certificato) + // ═══════════════════════════════════════════════════════════════════ + + public static byte[] RenderSingleSeriesToPng(UnderlyingChartData data, int width = 1100, int height = 700, bool jpeg = false) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); + + float titleBottom = DrawUnderlyingTitle(canvas, width, data.Nome); + + float marginLeft = 70; + float marginRight = 40; + float marginTop = titleBottom + 10; + float marginBottom = 45; + + var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom); + + var points = data.Points.OrderBy(p => p.Date).ToList(); + if (points.Count == 0) + return Array.Empty(); + + DateTime minDate = points[0].Date; + DateTime maxDate = points[^1].Date; + double minY = (double)points.Min(p => p.Px); + double maxY = (double)points.Max(p => p.Px); + double range = maxY - minY; + if (range == 0) range = Math.Max(minY * 0.1, 1); + double margin = range * 0.1; + minY -= margin; + maxY += margin; + + DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY); + DrawUnderlyingAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY); + DrawUnderlyingSeries(canvas, plotArea, points, minDate, maxDate, minY, maxY); + + using var borderPaint = new SKPaint + { + Color = SKColors.Gray, StrokeWidth = 1, + Style = SKPaintStyle.Stroke, IsAntialias = true, + }; + canvas.DrawRect(plotArea, borderPaint); + + using var image = surface.Snapshot(); + using var imgData = jpeg + ? image.Encode(SKEncodedImageFormat.Jpeg, 90) + : image.Encode(SKEncodedImageFormat.Png, 95); + return imgData.ToArray(); + } + + private static float DrawUnderlyingTitle(SKCanvas canvas, int width, string nome) + { + float y = 15f; + var boldFont = CreateFont(13f, bold: true); + using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true }; + canvas.DrawText(nome, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint); + return y + 22 + 5; + } + + private static void DrawUnderlyingAxisLabels(SKCanvas canvas, SKRect area, + DateTime minDate, DateTime maxDate, double minY, double maxY) + { + var font = CreateFont(11); + using var paint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true }; + + int ySteps = 8; + for (int i = 0; i <= ySteps; i++) + { + double val = maxY - ((maxY - minY) / ySteps) * i; + float y = area.Top + (area.Height / ySteps) * i; + canvas.DrawText($"{val:F2}", area.Left - 55, y + 4, SKTextAlign.Left, font, paint); + } + + double totalDays = (maxDate - minDate).TotalDays; + int intervalMonths = XAxisIntervalMonths(minDate, maxDate); + string fmt = totalDays > 365 ? "MMM yy" : "dd/MM/yy"; + + if (intervalMonths > 0) + { + var d = XAxisStart(minDate, intervalMonths); + while (d <= maxDate) + { + float x = DateToX(d, area, minDate, maxDate); + if (x >= area.Left && x <= area.Right) + canvas.DrawText(d.ToString(fmt), x, area.Bottom + 20, SKTextAlign.Center, font, paint); + d = d.AddMonths(intervalMonths); + } + } + else + { + canvas.DrawText(minDate.ToString("dd/MM/yy"), area.Left, area.Bottom + 20, SKTextAlign.Left, font, paint); + canvas.DrawText(maxDate.ToString("dd/MM/yy"), area.Right, area.Bottom + 20, SKTextAlign.Right, font, paint); + } + } + + private static void DrawUnderlyingSeries(SKCanvas canvas, SKRect area, + List points, + DateTime minDate, DateTime maxDate, double minY, double maxY) + { + using var paint = new SKPaint + { + Color = CertColor, StrokeWidth = 2f, + Style = SKPaintStyle.Stroke, IsAntialias = true, + StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round, + }; + + using var path = new SKPath(); + bool first = true; + foreach (var pt in points) + { + float x = DateToX(pt.Date, area, minDate, maxDate); + float y = ValueToY((double)pt.Px, area, minY, maxY); + y = Math.Max(area.Top, Math.Min(area.Bottom, y)); + if (first) { path.MoveTo(x, y); first = false; } + else path.LineTo(x, y); + } + + canvas.Save(); + canvas.ClipRect(area); + canvas.DrawPath(path, paint); + canvas.Restore(); + } +``` + +Aggiungere anche `using CertReports.Syncfusion.Models;` in cima al file se non già presente (è già presente, riga 1 — nessuna modifica necessaria). + +- [ ] **Step 2: Build per verificare che compili** + +Run: `dotnet build CertReports.Syncfusion` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs +git commit -m "feat: add RenderSingleSeriesToPng for standalone underlying chart" +``` + +--- + +## Task 5: Endpoint `GET /api/chart/underlying/{idUnderlyings}` + +**Files:** +- Modify: `CertReports.Syncfusion/Controllers/ChartController.cs` + +- [ ] **Step 1: Aggiungere il campo del nuovo servizio e iniettarlo nel costruttore** + +Modificare l'inizio della classe (righe 20-37): + +```csharp +public class ChartController : ControllerBase +{ + private readonly IChartDataService _chartDataService; + private readonly IChartDataServiceV2 _chartDataServiceV2; + private readonly IUnderlyingChartDataService _underlyingChartDataService; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public ChartController( + IChartDataService chartDataService, + IChartDataServiceV2 chartDataServiceV2, + IUnderlyingChartDataService underlyingChartDataService, + ILogger logger, + IConfiguration configuration) + { + _chartDataService = chartDataService; + _chartDataServiceV2 = chartDataServiceV2; + _underlyingChartDataService = underlyingChartDataService; + _logger = logger; + _configuration = configuration; + } +``` + +- [ ] **Step 2: Aggiungere la nuova route** + +Inserire subito dopo la chiusura del metodo `GenerateChartV2` (dopo la riga `}` che chiude quel metodo, prima del metodo `SaveChartToDiskAsync`): + +```csharp + /// + /// Endpoint standalone: grafico con solo la linea prezzo storico di UN sottostante, + /// identificato da IDUnderlyings — nessun contesto certificato (no strike/barriere/legenda). + /// Richiede SP cedlab_Chart_UnderlyingOnly nel DB. + /// Formati supportati: png (default), jpg/jpeg, pdf. Non supporta jpgEnc né ?save=true + /// (nessun alias-certificato applicabile a un sottostante puro). + /// + [HttpGet("underlying/{idUnderlyings:int}")] + public async Task GenerateChartUnderlying( + int idUnderlyings, + [FromQuery] int width = 1100, + [FromQuery] int height = 700, + [FromQuery] string format = "png") + { + if (idUnderlyings <= 0) + return BadRequest("IDUnderlyings non valido."); + + width = Math.Clamp(width, 400, 2000); + height = Math.Clamp(height, 300, 1500); + + try + { + var chartData = await _underlyingChartDataService.GetChartDataAsync(idUnderlyings); + + if (chartData == null || chartData.Points.Count == 0) + { + return NotFound(new + { + status = "KO", + message = $"Nessun dato per il grafico del sottostante {idUnderlyings}.", + }); + } + + bool isJpeg = format.Equals("jpg", StringComparison.OrdinalIgnoreCase) + || format.Equals("jpeg", StringComparison.OrdinalIgnoreCase); + + byte[] imgBytes = SkiaChartRendererV2.RenderSingleSeriesToPng(chartData, width, height, jpeg: isJpeg); + + if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + byte[] pdfBytes = WrapPngInPdf(imgBytes); + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.pdf"); + return File(pdfBytes, "application/pdf"); + } + + if (isJpeg) + { + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.jpg"); + return File(imgBytes, "image/jpeg"); + } + + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.png"); + return File(imgBytes, "image/png"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione chart sottostante per IDUnderlyings {IDUnderlyings}", idUnderlyings); + return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico sottostante." }); + } + } + +``` + +- [ ] **Step 3: Build per verificare che compili** + +Run: `dotnet build CertReports.Syncfusion` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 4: Commit** + +```bash +git add CertReports.Syncfusion/Controllers/ChartController.cs +git commit -m "feat: add GET /api/chart/underlying/{idUnderlyings} endpoint" +``` + +--- + +## Task 6: Verifica manuale end-to-end + +**Prerequisito**: lo script `docs/sql/cedlab_Chart_UnderlyingOnly.sql` (Task 1) deve essere già stato eseguito manualmente sul DB `FirstSolutionDB`. Serve inoltre un `IDUnderlyings` reale con storico prezzi in `dbo.Prices` — recuperabile con una query come `SELECT TOP 5 IDUnderlyings, Name, Ticker_bbg FROM dbo.Underlyings WHERE deleted = 0 AND sospeso = 0`. + +Non esistono test automatici nel progetto (vedi CLAUDE.md) — questa verifica è manuale, come per gli altri endpoint chart. + +- [ ] **Step 1: Avviare il progetto in locale** + +Run: `dotnet run --project CertReports.Syncfusion` +Expected: log di avvio Kestrel con l'URL locale (es. `https://localhost:XXXX`), nessuna eccezione. + +- [ ] **Step 2: Testare il caso positivo (PNG)** + +Aprire nel browser (sostituire `{porta}` e `{idUnderlyings}` con valori reali): +`https://localhost:{porta}/api/chart/underlying/{idUnderlyings}` + +Expected: immagine PNG con una sola linea nera, titolo `{Name} ({Ticker_bbg})` in alto, assi con date e valori prezzo (non percentuale), nessuna legenda. + +- [ ] **Step 3: Testare formato PDF** + +`https://localhost:{porta}/api/chart/underlying/{idUnderlyings}?format=pdf` + +Expected: PDF landscape con l'immagine centrata, apribile senza errori. + +- [ ] **Step 4: Testare caso 404 (sottostante inesistente)** + +`https://localhost:{porta}/api/chart/underlying/999999999` + +Expected: risposta HTTP 404 con body JSON `{"status":"KO","message":"Nessun dato per il grafico del sottostante 999999999."}`. + +- [ ] **Step 5: Testare caso 400 (id non valido)** + +`https://localhost:{porta}/api/chart/underlying/0` + +Expected: risposta HTTP 400. + +- [ ] **Step 6: Arrestare il server** + +Interrompere il processo `dotnet run` (Ctrl+C nel terminale in cui è in esecuzione). + +- [ ] **Step 7: Aggiornare CLAUDE.md** + +In `CertReports.Syncfusion/CLAUDE.md` (root repo), sezione `## API Endpoints`, aggiungere la riga: + +``` +- `GET /api/chart/underlying/{idUnderlyings}[?format=png|jpg|jpeg|pdf&width=&height=]` — grafico standalone di un solo sottostante (nessun contesto certificato, no strike/barriere/legenda) +``` + +- [ ] **Step 8: Commit** + +```bash +git add CertReports.Syncfusion/CLAUDE.md +git commit -m "docs: document GET /api/chart/underlying/{idUnderlyings} endpoint" +``` + +--- + +## Riepilogo copertura spec + +| Requisito spec | Task | +|---|---| +| SP `cedlab_Chart_UnderlyingOnly` con nome `{Name} ({Ticker_bbg})` | Task 1 | +| Modello `UnderlyingChartData` | Task 2 | +| Servizio dati + DI | Task 3 | +| Renderer solo linea prezzo, titolo, no legenda/barriere | Task 4 | +| Endpoint `GET /api/chart/underlying/{idUnderlyings}`, formati png/jpg/jpeg/pdf, no jpgEnc/save | Task 5 | +| Error handling 400/404/500 | Task 5 | +| Verifica end-to-end e doc | Task 6 |