Files
SmartReports/docs/superpowers/plans/2026-05-27-chart-v2.md
2026-07-21 11:02:32 +02:00

39 KiB

Chart V2 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/v2/{isin} che genera un grafico certificati migliorato con titolo, colori distinti CTF/WorstOf, label sulle linee costanti e legenda orizzontale in basso.

Architecture: Due nuove SP (cedlab_Chart_UL1 per metadata, cedlab_Chart_AllSeriesV2 per tutte le serie in un unico round-trip), tre nuovi file C# (modelli, servizio, renderer), e un secondo action method in ChartController. Il vecchio v1 rimane invariato.

Tech Stack: ASP.NET Core 8, SkiaSharp, Microsoft.Data.SqlClient, SQL Server (FirstSolutionDB)


Prerequisito DB — SP da creare PRIMA di eseguire i task C#

Le due stored procedure devono esistere in FirstSolutionDB. Schemi:

cedlab_Chart_UL1 @isin NVARCHAR(12)

Estende FSWeb_Chart_UL con campi aggiuntivi. Deve restituire:

Colonna Tipo Descrizione
IDCertificates int ID certificato
IDUnderlyings int ID sottostante
StartDate date Data inizio certificato
Strike decimal Valore strike assoluto
BarrieraCouponPerc decimal Barriera coupon in % (es. 60)
BarrieraCoupon decimal Barriera coupon assoluta
BarrieraCapitalePerc decimal Barriera capitale in % (es. 60)
BarrieraCapitale decimal Barriera capitale assoluta
Sottostante nvarchar Nome sottostante
IsWorstOf int 1 = è il worst-of, 0 = altri
PriceWorst decimal Prezzo attuale del worst-of (0 per altri)
PriceWorstPerc decimal Prezzo worst-of in % su strike (0 per altri)
NumPrezziCFT int Numero prezzi EOD disponibili
NomeCFT nvarchar Descrizione + ' - ' + ISIN
TriggerAutocallPerc decimal Trigger autocall in % (0 se assente)
AutocallValue decimal Trigger autocall assoluto

Ordinato: IsWorstOf DESC (worst-of nella prima riga).

cedlab_Chart_AllSeriesV2 @isin NVARCHAR(12)

Restituisce tutte le serie CTF + UL in un unico resultset:

Colonna Tipo Descrizione
IDUnderlyings int 0 = CTF, altrimenti = UnderlyingsID
Px_date date Data EOD
Performance decimal % su strike (CTF: PX_LAST_EOD/Nominal100; UL: Px_close(adj)/Strike100)

Logica:

  • TOP 350 per-serie via ROW_NUMBER() OVER (PARTITION BY IDUnderlyings ORDER BY Px_date DESC)
  • UL: solo date presenti nella serie CTF (INNER JOIN su Px_date), px_date >= StartDate
  • UL AdjustedPrices: CASE WHEN AdjustedPrices=1 THEN Px_closeadj ELSE Px_close END
  • ORDER BY IDUnderlyings ASC, Px_date ASC

File da creare / modificare

Operazione File
Crea CertReports.Syncfusion/Models/ChartModelsV2.cs
Crea CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs
Crea CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs
Modifica CertReports.Syncfusion/Controllers/ChartController.cs
Modifica CertReports.Syncfusion/Program.cs

Task 1: ChartModelsV2

Files:

  • Create: CertReports.Syncfusion/Models/ChartModelsV2.cs

  • Step 1: Crea il file

namespace CertReports.Syncfusion.Models;

/// <summary>
/// Metadata di un sottostante (da SP cedlab_Chart_UL1).
/// Prima riga = worst-of (IsWorstOf = 1).
/// </summary>
public class ChartUlMetadata
{
    public int IDCertificates { get; set; }
    public int IDUnderlyings { get; set; }
    public DateTime StartDate { get; set; }
    public decimal Strike { get; set; }
    public decimal BarrieraCouponPerc { get; set; }
    public decimal BarrieraCoupon { get; set; }
    public decimal BarrieraCapitalePerc { get; set; }
    public decimal BarrieraCapitale { get; set; }
    public string Sottostante { get; set; } = string.Empty;
    public int IsWorstOf { get; set; }
    public decimal PriceWorst { get; set; }
    public decimal PriceWorstPerc { get; set; }
    public int NumPrezziCFT { get; set; }
    public string NomeCFT { get; set; } = string.Empty;
    public decimal TriggerAutocallPerc { get; set; }
    public decimal AutocallValue { get; set; }
}

/// <summary>
/// Singolo punto di una serie (da SP cedlab_Chart_AllSeriesV2).
/// IDUnderlyings = 0 → serie del certificato.
/// </summary>
public class ChartSeriesPoint
{
    public int IDUnderlyings { get; set; }
    public DateTime Date { get; set; }
    public decimal Performance { get; set; }
}

/// <summary>
/// Dati completi per il grafico V2.
/// </summary>
public class ChartDataV2
{
    public string Isin { get; set; } = string.Empty;

    /// <summary>
    /// Metadata globale: prima riga di cedlab_Chart_UL1 (il worst-of).
    /// Contiene NomeCFT, NumPrezziCFT, barriere, trigger — uguali per tutte le righe.
    /// </summary>
    public ChartUlMetadata GlobalMeta { get; set; } = new();

    /// <summary>Tutti i sottostanti (per IsWorstOf, PriceWorst, nomi legenda).</summary>
    public List<ChartUlMetadata> Underlyings { get; set; } = new();

    /// <summary>Tutti i punti di tutte le serie (CTF + UL), ordinati per data.</summary>
    public List<ChartSeriesPoint> SeriesPoints { get; set; } = new();
}
  • Step 2: Verifica compilazione
dotnet build CertReports.Syncfusion

Expected: Build succeeded. 0 Error(s)

  • Step 3: Commit
git add CertReports.Syncfusion/Models/ChartModelsV2.cs
git commit -m "feat: add ChartModelsV2 (ChartUlMetadata, ChartSeriesPoint, ChartDataV2)"

Task 2: ChartDataServiceV2

Files:

  • Create: CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs

⚠️ Prerequisito: le SP cedlab_Chart_UL1 e cedlab_Chart_AllSeriesV2 devono già esistere nel DB.

  • Step 1: Crea il file
using CertReports.Syncfusion.Models;
using Microsoft.Data.SqlClient;
using System.Data;

namespace CertReports.Syncfusion.Services.Implementations;

/// <summary>
/// Recupera i dati per il grafico V2 con solo 2 round-trip al DB.
///
/// SP utilizzate:
///   - cedlab_Chart_UL1:          Metadata sottostanti (1 query, N sottostanti)
///   - cedlab_Chart_AllSeriesV2:  Tutte le serie CTF + UL in una query (TOP 350 per-serie)
/// </summary>
public interface IChartDataServiceV2
{
    Task<ChartDataV2?> GetChartDataV2Async(string isin);
}

public class ChartDataServiceV2 : IChartDataServiceV2
{
    private readonly string _connectionString;
    private readonly ILogger<ChartDataServiceV2> _logger;

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

    public async Task<ChartDataV2?> GetChartDataV2Async(string isin)
    {
        await using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync();

        // ── 1. Metadata sottostanti (cedlab_Chart_UL1) ─────────────────
        var underlyings = new List<ChartUlMetadata>();

        await using (var cmd = new SqlCommand("cedlab_Chart_UL1", conn)
            { CommandType = CommandType.StoredProcedure })
        {
            cmd.Parameters.AddWithValue("@isin", isin);
            await using var r = await cmd.ExecuteReaderAsync();
            while (await r.ReadAsync())
            {
                underlyings.Add(new ChartUlMetadata
                {
                    IDCertificates    = r.GetInt32(r.GetOrdinal("IDCertificates")),
                    IDUnderlyings     = r.GetInt32(r.GetOrdinal("IDUnderlyings")),
                    StartDate         = r.GetDateTime(r.GetOrdinal("StartDate")),
                    Strike            = r.GetDecimal(r.GetOrdinal("Strike")),
                    BarrieraCouponPerc    = r.GetDecimal(r.GetOrdinal("BarrieraCouponPerc")),
                    BarrieraCoupon        = r.GetDecimal(r.GetOrdinal("BarrieraCoupon")),
                    BarrieraCapitalePerc  = r.GetDecimal(r.GetOrdinal("BarrieraCapitalePerc")),
                    BarrieraCapitale      = r.GetDecimal(r.GetOrdinal("BarrieraCapitale")),
                    Sottostante       = r.GetString(r.GetOrdinal("Sottostante")),
                    IsWorstOf         = r.GetInt32(r.GetOrdinal("IsWorstOf")),
                    PriceWorst        = r.GetDecimal(r.GetOrdinal("PriceWorst")),
                    PriceWorstPerc    = r.GetDecimal(r.GetOrdinal("PriceWorstPerc")),
                    NumPrezziCFT      = r.GetInt32(r.GetOrdinal("NumPrezziCFT")),
                    NomeCFT           = r.GetString(r.GetOrdinal("NomeCFT")),
                    TriggerAutocallPerc = r.GetDecimal(r.GetOrdinal("TriggerAutocallPerc")),
                    AutocallValue     = r.GetDecimal(r.GetOrdinal("AutocallValue")),
                });
            }
        }

        if (underlyings.Count == 0)
        {
            _logger.LogWarning(
                "Nessun sottostante trovato per il grafico V2 di {Isin} (meno di 30 prezzi EOD?)", isin);
            return null;
        }

        var result = new ChartDataV2
        {
            Isin       = isin,
            GlobalMeta = underlyings[0],   // worst-of è il primo (SP ordina IsWorstOf DESC)
            Underlyings = underlyings,
        };

        // ── 2. Tutte le serie (cedlab_Chart_AllSeriesV2) ────────────────
        await using (var cmd = new SqlCommand("cedlab_Chart_AllSeriesV2", conn)
            { CommandType = CommandType.StoredProcedure })
        {
            cmd.Parameters.AddWithValue("@isin", isin);
            await using var r = await cmd.ExecuteReaderAsync();
            while (await r.ReadAsync())
            {
                result.SeriesPoints.Add(new ChartSeriesPoint
                {
                    IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")),
                    Date          = r.GetDateTime(r.GetOrdinal("Px_date")),
                    Performance   = r.GetDecimal(r.GetOrdinal("Performance")),
                });
            }
        }

        _logger.LogInformation(
            "Dati grafico V2 caricati per {Isin}: {UlCount} sottostanti, {Points} punti totali",
            isin, underlyings.Count, result.SeriesPoints.Count);

        return result;
    }
}
  • Step 2: Verifica compilazione
dotnet build CertReports.Syncfusion

Expected: Build succeeded. 0 Error(s)

  • Step 3: Commit
git add CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs
git commit -m "feat: add ChartDataServiceV2 with IChartDataServiceV2 (2-SP approach)"

Task 3: SkiaChartRendererV2

Files:

  • Create: CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs

  • Step 1: Crea il file (~380 righe)

using CertReports.Syncfusion.Models;
using SkiaSharp;

namespace CertReports.Syncfusion.Services.Implementations;

/// <summary>
/// Renderer grafico V2 per certificati con SkiaSharp.
///
/// Miglioramenti rispetto a v1:
///   - Titolo in cima (NomeCFT + avviso se < 30 prezzi)
///   - CTF in rosso (#CC0000), WorstOf in blu (#1565C0), altri in grigio
///   - Linee costanti con label direttamente sull'estremità destra
///   - Legenda orizzontale in BASSO (non a destra)
///   - Linea tratteggiata blu per prezzo attuale worst-of (non in legenda)
/// </summary>
public static class SkiaChartRendererV2
{
    // ── Colori V2 ──────────────────────────────────────────────────────
    private static readonly SKColor CertColor      = new(204, 0, 0);       // #CC0000 rosso CTF
    private static readonly SKColor WorstOfColor   = new(21, 101, 192);    // #1565C0 blu WorstOf
    private static readonly SKColor StrikeColor    = new(46, 125, 50);     // #2E7D32 verde
    private static readonly SKColor CapitaleColor  = new(204, 0, 0);       // rosso (= CTF)
    private static readonly SKColor CouponColor    = new(128, 0, 128);     // viola
    private static readonly SKColor AutocallColor  = new(230, 81, 0);      // arancione
    private static readonly SKColor PrezzoWorstColor = new(21, 101, 192);  // blu tratteggiato
    private static readonly SKColor TitleColor     = new(21, 101, 192);    // blu titolo

    private static readonly SKColor[] OtherUlColors =
    {
        new(120, 120, 120),
        new(160, 160, 160),
        new(90,  90,  90),
        new(140, 140, 140),
    };

    // ── Font ───────────────────────────────────────────────────────────
    private static SKFont CreateFont(float size, bool bold = false) =>
        new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size);

    // ═══════════════════════════════════════════════════════════════════
    // Entry point
    // ═══════════════════════════════════════════════════════════════════

    /// <summary>Genera il grafico come PNG.</summary>
    public static byte[] RenderToPng(ChartDataV2 data, int width = 1100, int height = 700)
    {
        using var surface = SKSurface.Create(new SKImageInfo(width, height));
        var canvas = surface.Canvas;
        canvas.Clear(SKColors.White);

        // ── Titolo ─────────────────────────────────────────────────────
        float titleBottom = DrawTitle(canvas, width, data);

        // ── Margini area plot ──────────────────────────────────────────
        float marginLeft   = 70;
        float marginRight  = 210;              // spazio per label linee costanti
        float marginTop    = titleBottom + 10;
        float marginBottom = 95;               // asse X + legenda orizzontale

        var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom);

        // ── Raggruppa punti per serie ──────────────────────────────────
        var worstOf = data.Underlyings.FirstOrDefault(u => u.IsWorstOf == 1);
        var seriesByUl = data.SeriesPoints
            .GroupBy(p => p.IDUnderlyings)
            .ToDictionary(g => g.Key, g => g.OrderBy(p => p.Date).ToList());

        if (seriesByUl.Count == 0)
            return Array.Empty<byte>();

        // ── Calcola range assi ─────────────────────────────────────────
        var (minDate, maxDate, minY, maxY) = CalculateRanges(data, seriesByUl, worstOf);

        // ── Griglia e assi ─────────────────────────────────────────────
        DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY);
        DrawAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY);

        // ── Linee costanti con label ───────────────────────────────────
        var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();

        // Barriera Capitale (label unificata se stessa % di Coupon)
        string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc
            ? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})"
            : $"Barriera Capitale {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})";
        DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
            (float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel);
        constLegend.Add((bcLabel, CapitaleColor, false, 1.5f));

        // Barriera Coupon (solo se diversa)
        if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc
            && data.GlobalMeta.BarrieraCouponPerc > 0)
        {
            string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})";
            DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
                (float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel);
            constLegend.Add((bkLabel, CouponColor, false, 1.5f));
        }

        // Strike
        string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})";
        DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
            100f, StrikeColor, 1.5f, false, strikeLabel);
        constLegend.Add((strikeLabel, StrikeColor, false, 1.5f));

        // Trigger Autocall (solo se applicabile)
        bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
            && data.GlobalMeta.TriggerAutocallPerc != 100
            && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
            && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
        if (showAutocall)
        {
            string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})";
            DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
                (float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel);
            constLegend.Add((taLabel, AutocallColor, false, 1.5f));
        }

        // Prezzo attuale WorstOf — tratteggiato, NON in legenda
        if (worstOf != null && worstOf.PriceWorstPerc > 0)
        {
            string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})";
            DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
                (float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel);
        }

        // ── Serie ──────────────────────────────────────────────────────
        var seriesLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
        int otherColorIdx = 0;

        // CTF (IDUnderlyings = 0)
        if (seriesByUl.TryGetValue(0, out var ctfPoints) && ctfPoints.Count >= 2)
        {
            DrawSeriesV2(canvas, plotArea, ctfPoints, minDate, maxDate, minY, maxY, CertColor, 2.5f);
            seriesLegend.Add((data.Isin, CertColor, false, 2.5f));
        }

        // Sottostanti (WorstOf prima, poi altri)
        foreach (var ul in data.Underlyings.OrderByDescending(u => u.IsWorstOf))
        {
            if (!seriesByUl.TryGetValue(ul.IDUnderlyings, out var ulPoints) || ulPoints.Count < 2)
                continue;

            SKColor color;
            float thickness;
            if (ul.IsWorstOf == 1)
            {
                color = WorstOfColor;
                thickness = 2f;
            }
            else
            {
                color = OtherUlColors[otherColorIdx++ % OtherUlColors.Length];
                thickness = 1f;
            }

            DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness);
            seriesLegend.Add((ul.Sottostante, color, false, thickness));
        }

        // ── Bordo area plot ────────────────────────────────────────────
        using var borderPaint = new SKPaint
        {
            Color = SKColors.Gray, StrokeWidth = 1,
            Style = SKPaintStyle.Stroke, IsAntialias = true,
        };
        canvas.DrawRect(plotArea, borderPaint);

        // ── Legenda orizzontale in basso ───────────────────────────────
        var allLegend = seriesLegend.Concat(constLegend).ToList();
        DrawLegendBottom(canvas, plotArea, allLegend, width);

        // ── Export PNG ─────────────────────────────────────────────────
        using var image = surface.Snapshot();
        using var pngData = image.Encode(SKEncodedImageFormat.Png, 95);
        return pngData.ToArray();
    }

    // ═══════════════════════════════════════════════════════════════════
    // Titolo
    // ═══════════════════════════════════════════════════════════════════

    private static float DrawTitle(SKCanvas canvas, int width, ChartDataV2 data)
    {
        float y = 15f;

        // Riga 1: NomeCFT in grassetto blu
        using var boldFont = CreateFont(13f, bold: true);
        using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true };
        canvas.DrawText(data.GlobalMeta.NomeCFT, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint);
        y += 22;

        // Riga 2 (opzionale): avviso se meno di 30 prezzi CTF
        if (data.GlobalMeta.NumPrezziCFT < 30)
        {
            using var subFont = CreateFont(10f);
            using var subPaint = new SKPaint { Color = new SKColor(204, 0, 0), IsAntialias = true };
            const string subtitle = "Il certificato viene mostrato nel grafico solo dopo 30gg dalla sua emissione";
            canvas.DrawText(subtitle, width / 2f, y + 11, SKTextAlign.Center, subFont, subPaint);
            y += 18;
        }

        return y + 5; // bottom della zona titolo
    }

    // ═══════════════════════════════════════════════════════════════════
    // Calcolo range assi
    // ═══════════════════════════════════════════════════════════════════

    private static (DateTime minDate, DateTime maxDate, double minY, double maxY) CalculateRanges(
        ChartDataV2 data,
        Dictionary<int, List<ChartSeriesPoint>> seriesByUl,
        ChartUlMetadata? worstOf)
    {
        DateTime minDate = DateTime.MaxValue, maxDate = DateTime.MinValue;
        double minY = double.MaxValue, maxY = double.MinValue;

        foreach (var pts in seriesByUl.Values)
        {
            foreach (var pt in pts)
            {
                if (pt.Date < minDate) minDate = pt.Date;
                if (pt.Date > maxDate) maxDate = pt.Date;
                double v = (double)pt.Performance;
                if (v < minY) minY = v;
                if (v > maxY) maxY = v;
            }
        }

        // Includi linee costanti nel range
        var constants = new List<double>
        {
            100.0,
            (double)data.GlobalMeta.BarrieraCapitalePerc,
        };
        if (data.GlobalMeta.BarrieraCouponPerc > 0)
            constants.Add((double)data.GlobalMeta.BarrieraCouponPerc);
        if (showAutocallValue(data))
            constants.Add((double)data.GlobalMeta.TriggerAutocallPerc);
        if (worstOf != null && worstOf.PriceWorstPerc > 0)
            constants.Add((double)worstOf.PriceWorstPerc);

        foreach (var c in constants)
        {
            if (c < minY) minY = c;
            if (c > maxY) maxY = c;
        }

        // Margine 10%
        double range = maxY - minY;
        if (range == 0) range = 10;
        double margin = range * 0.1;
        minY -= margin;
        maxY += margin;

        return (minDate, maxDate, minY, maxY);
    }

    private static bool showAutocallValue(ChartDataV2 data) =>
        data.GlobalMeta.TriggerAutocallPerc != 0
        && data.GlobalMeta.TriggerAutocallPerc != 100
        && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
        && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;

    // ═══════════════════════════════════════════════════════════════════
    // Griglia
    // ═══════════════════════════════════════════════════════════════════

    private static void DrawGrid(SKCanvas canvas, SKRect area,
        DateTime minDate, DateTime maxDate, double minY, double maxY)
    {
        using var gridPaint = new SKPaint
        {
            Color = new SKColor(230, 230, 230), StrokeWidth = 0.5f,
            Style = SKPaintStyle.Stroke, IsAntialias = true,
        };

        int ySteps = 8;
        for (int i = 0; i <= ySteps; i++)
        {
            float y = area.Top + (area.Height / ySteps) * i;
            canvas.DrawLine(area.Left, y, area.Right, y, gridPaint);
        }

        var totalDays = (maxDate - minDate).TotalDays;
        int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
        var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
        while (d <= maxDate)
        {
            float x = DateToX(d, area, minDate, maxDate);
            if (x >= area.Left && x <= area.Right)
                canvas.DrawLine(x, area.Top, x, area.Bottom, gridPaint);
            d = d.AddDays(step);
        }
    }

    // ═══════════════════════════════════════════════════════════════════
    // Labels assi
    // ═══════════════════════════════════════════════════════════════════

    private static void DrawAxisLabels(SKCanvas canvas, SKRect area,
        DateTime minDate, DateTime maxDate, double minY, double maxY)
    {
        using 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:F0} %", area.Left - 55, y + 4, SKTextAlign.Left, font, paint);
        }

        var totalDays = (maxDate - minDate).TotalDays;
        int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
        var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
        while (d <= maxDate)
        {
            float x = DateToX(d, area, minDate, maxDate);
            if (x >= area.Left && x <= area.Right)
            {
                string text = totalDays > 500 ? d.ToString("yyyy") : d.ToString("MMM yyyy");
                canvas.DrawText(text, x, area.Bottom + 20, SKTextAlign.Center, font, paint);
            }
            d = d.AddDays(step);
        }
    }

    // ═══════════════════════════════════════════════════════════════════
    // Disegno serie
    // ═══════════════════════════════════════════════════════════════════

    private static void DrawSeriesV2(SKCanvas canvas, SKRect area,
        List<ChartSeriesPoint> points,
        DateTime minDate, DateTime maxDate, double minY, double maxY,
        SKColor color, float thickness)
    {
        using var paint = new SKPaint
        {
            Color = color, StrokeWidth = thickness,
            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.Performance, 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();
    }

    // ═══════════════════════════════════════════════════════════════════
    // Linea orizzontale costante con label a destra
    // ═══════════════════════════════════════════════════════════════════

    private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area,
        double minY, double maxY,
        float value, SKColor color, float thickness, bool dashed, string label)
    {
        float y = ValueToY(value, area, minY, maxY);
        if (y < area.Top || y > area.Bottom) return;

        using var linePaint = new SKPaint
        {
            Color = color, StrokeWidth = thickness,
            Style = SKPaintStyle.Stroke, IsAntialias = true,
        };
        if (dashed)
            linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0);

        canvas.DrawLine(area.Left, y, area.Right, y, linePaint);

        // Label a destra della linea
        using var font = CreateFont(9.5f);
        using var textPaint = new SKPaint { Color = color, IsAntialias = true };
        canvas.DrawText(label, area.Right + 5, y + 4, SKTextAlign.Left, font, textPaint);
    }

    // ═══════════════════════════════════════════════════════════════════
    // Legenda orizzontale in basso (flow layout, max 2 righe)
    // ═══════════════════════════════════════════════════════════════════

    private static void DrawLegendBottom(SKCanvas canvas, SKRect plotArea,
        List<(string name, SKColor color, bool dashed, float thickness)> items, int totalWidth)
    {
        if (items.Count == 0) return;

        float legendY = plotArea.Bottom + 38;  // sotto le label asse X
        float x = plotArea.Left;
        const float lineW = 22;
        const float gap = 6;
        const float itemGap = 14;
        const float rowHeight = 19;

        using var font = CreateFont(10f);

        foreach (var (name, color, dashed, thickness) in items)
        {
            float textW = font.MeasureText(name);
            float itemW = lineW + gap + textW + itemGap;

            // Vai a capo se non c'è spazio
            if (x + itemW > totalWidth - plotArea.Left + plotArea.Left && x > plotArea.Left)
            {
                x = plotArea.Left;
                legendY += rowHeight;
            }

            float midY = legendY + rowHeight / 2f - 2;

            // Linea campione
            using var linePaint = new SKPaint
            {
                Color = color, StrokeWidth = Math.Min(thickness, 2f),
                Style = SKPaintStyle.Stroke, IsAntialias = true,
            };
            if (dashed)
                linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 6f, 3f }, 0);
            canvas.DrawLine(x, midY, x + lineW, midY, linePaint);

            // Testo
            using var textPaint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
            canvas.DrawText(name, x + lineW + gap, midY + 4, SKTextAlign.Left, font, textPaint);

            x += itemW;
        }
    }

    // ═══════════════════════════════════════════════════════════════════
    // Conversioni coordinate (identiche a v1)
    // ═══════════════════════════════════════════════════════════════════

    private static float DateToX(DateTime date, SKRect area, DateTime minDate, DateTime maxDate)
    {
        double totalDays = (maxDate - minDate).TotalDays;
        if (totalDays == 0) return area.Left;
        double ratio = (date - minDate).TotalDays / totalDays;
        return area.Left + (float)(ratio * area.Width);
    }

    private static float ValueToY(double value, SKRect area, double minY, double maxY)
    {
        double range = maxY - minY;
        if (range == 0) return area.MidY;
        double ratio = (value - minY) / range;
        return area.Bottom - (float)(ratio * area.Height);
    }
}
  • Step 2: Verifica compilazione
dotnet build CertReports.Syncfusion

Expected: Build succeeded. 0 Error(s)

💡 Se il build fallisce su showAutocallValue (metodo privato statico con nome in minuscolo): rinomina in ShowAutocallValue e aggiorna i due riferimenti in CalculateRanges e nel body di RenderToPng.

  • Step 3: Commit
git add CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs
git commit -m "feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend)"

Task 4: Controller V2 + DI

Files:

  • Modify: CertReports.Syncfusion/Controllers/ChartController.cs

  • Modify: CertReports.Syncfusion/Program.cs

  • Step 1: Aggiorna ChartController.cs — aggiungi injection e action V2

Aggiungi IChartDataServiceV2 _chartDataServiceV2 al controller. Modifica il costruttore e aggiungi il nuovo action method:

Nella sezione campi (dopo private readonly IChartDataService _chartDataService;):

private readonly IChartDataServiceV2 _chartDataServiceV2;

Sostituisci il costruttore esistente:

// PRIMA:
public ChartController(IChartDataService chartDataService, ILogger<ChartController> logger)
{
    _chartDataService = chartDataService;
    _logger = logger;
}

// DOPO:
public ChartController(
    IChartDataService chartDataService,
    IChartDataServiceV2 chartDataServiceV2,
    ILogger<ChartController> logger)
{
    _chartDataService = chartDataService;
    _chartDataServiceV2 = chartDataServiceV2;
    _logger = logger;
}

Aggiungi il nuovo action method subito dopo il } di GenerateChart e prima di WrapPngInPdf:

/// <summary>
/// Endpoint V2: grafico migliorato con titolo, colori distinti, label sulle linee e legenda in basso.
/// Richiede SP cedlab_Chart_UL1 e cedlab_Chart_AllSeriesV2 nel DB.
/// </summary>
[HttpGet("v2/{isin}")]
public async Task<IActionResult> GenerateChartV2(
    string isin,
    [FromQuery] int width = 1100,
    [FromQuery] int height = 700,
    [FromQuery] string format = "png")
{
    if (string.IsNullOrWhiteSpace(isin))
        return BadRequest("ISIN non valido.");

    width  = Math.Clamp(width,  400, 2000);
    height = Math.Clamp(height, 300, 1500);

    try
    {
        var chartData = await _chartDataServiceV2.GetChartDataV2Async(isin);

        if (chartData == null || chartData.SeriesPoints.Count == 0)
        {
            return NotFound(new
            {
                status  = "KO",
                message = $"Nessun dato per il grafico V2 di {isin} (meno di 30 prezzi EOD?).",
            });
        }

        byte[] pngBytes = SkiaChartRendererV2.RenderToPng(chartData, width, height);

        if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
        {
            byte[] pdfBytes = WrapPngInPdf(pngBytes);
            Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.pdf");
            return File(pdfBytes, "application/pdf");
        }

        Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.png");
        return File(pngBytes, "image/png");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Errore generazione chart V2 per ISIN {Isin}", isin);
        return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico V2." });
    }
}
  • Step 2: Registra il servizio in Program.cs

Aggiungi questa riga subito dopo builder.Services.AddScoped<IChartDataService, ChartDataService>();:

builder.Services.AddScoped<IChartDataServiceV2, ChartDataServiceV2>();
  • Step 3: Verifica compilazione
dotnet build CertReports.Syncfusion

Expected: Build succeeded. 0 Error(s)

  • Step 4: Test manuale — avvia l'API
dotnet run --project CertReports.Syncfusion
  • Step 5: Chiama il nuovo endpoint
GET https://localhost:{porta}/api/chart/v2/{ISIN}
GET https://localhost:{porta}/api/chart/v2/{ISIN}?format=pdf
GET https://localhost:{porta}/api/chart/v2/{ISIN}?width=1400&height=800

Verifica nel PNG/PDF che:

  • Titolo NomeCFT visibile in blu in cima

  • Linea certificato in rosso (non nera come v1)

  • Linea worst-of in blu più spessa degli altri UL

  • Linee barriere con label testuale a destra (non solo in legenda)

  • Legenda in basso (non a destra)

  • Linea tratteggiata blu per prezzo attuale worst-of (se PriceWorstPerc > 0)

  • Se NumPrezziCFT < 30: sub-titolo rosso con avviso

  • Step 6: Commit

git add CertReports.Syncfusion/Controllers/ChartController.cs
git add CertReports.Syncfusion/Program.cs
git commit -m "feat: add /api/chart/v2/{isin} endpoint and register IChartDataServiceV2"

Self-Review

Spec coverage

  • 2 SP (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2) → Task 2
  • Modelli ChartUlMetadata, ChartSeriesPoint, ChartDataV2 → Task 1
  • Titolo NomeCFT + sub-titolo se < 30 prezzi → Task 3 DrawTitle
  • CTF rosso 2.5px, WorstOf blu 2px, altri grigi 1px → Task 3 serie loop
  • Label su linee costanti (Strike, Barriera Capitale, Coupon, Autocall) → Task 3 DrawHorizontalLineWithLabel
  • Label unificata se BarrieraCouponPerc == BarrieraCapitalePerc → Task 3
  • TriggerAutocall: solo se != 0 && != 100 && != Capitale && != Coupon → Task 3
  • PrezzoWorst tratteggiato, NON in legenda → Task 3
  • Legenda orizzontale in basso → Task 3 DrawLegendBottom
  • Endpoint /api/chart/v2/{isin} con ?width ?height ?format → Task 4
  • Riuso di WrapPngInPdf dal v1 → Task 4
  • Registrazione DI → Task 4

Note implementative

  • Il DrawLegendBottom usa un layout flow. Se x + itemW > totalWidth - plotArea.Left + plotArea.Left la condizione si semplifica a x + itemW > totalWidth. Verificare a runtime se la legenda deborda; se sì, diminuire il font a 9.5f o ridurre itemGap.
  • marginBottom = 95 dà spazio per asse X (20px) + 2 righe di legenda (38px). Se gli item leggenda sono molti, aumentare a 110.