Files
SmartReports/docs/superpowers/plans/2026-07-21-underlying-only-chart.md

21 KiB

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

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
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

namespace CertReports.Syncfusion.Models;

/// <summary>
/// Singolo punto prezzo per il grafico standalone sottostante
/// (da SP cedlab_Chart_UnderlyingOnly).
/// </summary>
public class UnderlyingChartPoint
{
    public DateTime Date { get; set; }
    public decimal Px { get; set; }
}

/// <summary>
/// Dati completi per il grafico standalone di un sottostante,
/// senza alcun contesto certificato.
/// </summary>
public class UnderlyingChartData
{
    public int IDUnderlyings { get; set; }

    /// <summary>Titolo del grafico: "{Name} ({Ticker_bbg})".</summary>
    public string Nome { get; set; } = string.Empty;

    public List<UnderlyingChartPoint> 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
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

using CertReports.Syncfusion.Models;
using Microsoft.Data.SqlClient;
using System.Data;

namespace CertReports.Syncfusion.Services.Implementations;

/// <summary>
/// Recupera i dati per il grafico standalone di un singolo sottostante
/// (nessun contesto certificato). SP utilizzata: cedlab_Chart_UnderlyingOnly.
/// </summary>
public interface IUnderlyingChartDataService
{
    Task<UnderlyingChartData?> GetChartDataAsync(int idUnderlyings);
}

public class UnderlyingChartDataService : IUnderlyingChartDataService
{
    private readonly string _connectionString;
    private readonly ILogger<UnderlyingChartDataService> _logger;

    public UnderlyingChartDataService(IConfiguration config, ILogger<UnderlyingChartDataService> logger)
    {
        _connectionString = config.GetConnectionString("CertDb")
            ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
        _logger = logger;
    }

    public async Task<UnderlyingChartData?> 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<IChartDataServiceV2, ChartDataServiceV2>();):

builder.Services.AddScoped<IUnderlyingChartDataService, UnderlyingChartDataService>();
  • Step 3: Build per verificare che compili

Run: dotnet build CertReports.Syncfusion Expected: Build succeeded. 0 Error(s)

  • Step 4: Commit
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):

    // ═══════════════════════════════════════════════════════════════════
    // 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<byte>();

        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<UnderlyingChartPoint> 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
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):

public class ChartController : ControllerBase
{
    private readonly IChartDataService _chartDataService;
    private readonly IChartDataServiceV2 _chartDataServiceV2;
    private readonly IUnderlyingChartDataService _underlyingChartDataService;
    private readonly ILogger<ChartController> _logger;
    private readonly IConfiguration _configuration;

    public ChartController(
        IChartDataService chartDataService,
        IChartDataServiceV2 chartDataServiceV2,
        IUnderlyingChartDataService underlyingChartDataService,
        ILogger<ChartController> 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):

    /// <summary>
    /// 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).
    /// </summary>
    [HttpGet("underlying/{idUnderlyings:int}")]
    public async Task<IActionResult> 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
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
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