diff --git a/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml b/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml
new file mode 100644
index 0000000..9542e00
--- /dev/null
+++ b/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml
@@ -0,0 +1,26 @@
+
+
+
+
+ MSDeploy
+ **\*.pdb
+ true
+ Release
+ Any CPU
+ https://www.smart-roots.net/smartreports
+ false
+ 6ef1f59d-feab-9797-733a-5711f6c6221b
+ false
+ http://26.69.45.60
+ /smart-roots/smartreports
+
+ true
+ RemoteAgent
+ true
+ true
+ Administrator
+ <_SavePWD>true
+ <_TargetId>IISWebDeploy
+ net8.0
+
+
\ No newline at end of file
diff --git a/CertReports.Syncfusion/dotnet-tools.json b/CertReports.Syncfusion/dotnet-tools.json
new file mode 100644
index 0000000..b6f4c2f
--- /dev/null
+++ b/CertReports.Syncfusion/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.5",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-03-23-dividend-section.md b/docs/superpowers/plans/2026-03-23-dividend-section.md
new file mode 100644
index 0000000..78e8938
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-23-dividend-section.md
@@ -0,0 +1,789 @@
+# Dividend Section 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 il parametro `?dividend=true` che sostituisce la tabella Sottostanti nelle sezioni Anagrafica con una pagina landscape dedicata Sottostanti+Dividendi.
+
+**Architecture:** Il flag `ShowDividend` viene aggiunto a `CertificateReportData` e propagato attraverso controller → orchestratore → renderer. `DividendSectionRenderer` è un renderer diretto (non `IPdfSectionRenderer`) iniettato nell'orchestratore come già avviene per `ExpiredAnagraficaSectionRenderer`. Entrambi i flussi (attivo + expired) ricevono la pagina landscape subito dopo la Sezione 1. Le chiavi cache vengono estese a 4 combinazioni.
+
+**Tech Stack:** ASP.NET Core 8, Syncfusion PDF Net.Core v33, SkiaSharp, Microsoft.Data.SqlClient
+
+---
+
+## File Map
+
+| File | Azione |
+|------|--------|
+| `Models/CertificateModels.cs` | Modify — aggiungere `ShowDividend` |
+| `Services/Interfaces/IServices.cs` | Modify — aggiungere `showDividend` a `IReportOrchestrator` |
+| `Controllers/ReportController.cs` | Modify — aggiungere param `dividend` a tutti e 3 gli endpoint |
+| `Services/Implementations/ReportOrchestrator.cs` | Modify — signature, cache keys, inject renderer, inserire sezione |
+| `Services/Implementations/AnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` |
+| `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` |
+| `Services/Implementations/DividendSectionRenderer.cs` | Create — nuova pagina landscape con tabella a 2 livelli |
+| `Program.cs` | Modify — registrare `DividendSectionRenderer` |
+
+---
+
+## Task 1: Aggiungere `ShowDividend` al modello
+
+**Files:**
+- Modify: `Models/CertificateModels.cs`
+
+- [ ] **Step 1: Leggere il file corrente**
+
+ Aprire `Models/CertificateModels.cs` e individuare la classe `CertificateReportData`.
+ Il file contiene già `public bool ShowBranding { get; set; } = false;`.
+
+- [ ] **Step 2: Aggiungere la proprietà**
+
+ Subito dopo `ShowBranding`:
+ ```csharp
+ public bool ShowBranding { get; set; } = false;
+ public bool ShowDividend { get; set; } = false;
+ ```
+
+- [ ] **Step 3: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Models/CertificateModels.cs
+ git commit -m "feat: add ShowDividend flag to CertificateReportData"
+ ```
+
+---
+
+## Task 2: Aggiornare l'interfaccia `IReportOrchestrator`
+
+**Files:**
+- Modify: `Services/Interfaces/IServices.cs`
+
+- [ ] **Step 1: Leggere il file**
+
+ Trovare `IReportOrchestrator`. Attualmente:
+ ```csharp
+ public interface IReportOrchestrator
+ {
+ Task GenerateReportAsync(string isin, bool showBranding = false);
+ }
+ ```
+
+- [ ] **Step 2: Aggiungere il parametro**
+
+ ```csharp
+ public interface IReportOrchestrator
+ {
+ Task GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false);
+ }
+ ```
+
+- [ ] **Step 3: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: errore di compilazione su `ReportOrchestrator` (firma non corrisponde) — normale, fix al Task 4.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Services/Interfaces/IServices.cs
+ git commit -m "feat: add showDividend param to IReportOrchestrator interface"
+ ```
+
+---
+
+## Task 3: Aggiornare `ReportController` — parametro `dividend`
+
+**Files:**
+- Modify: `Controllers/ReportController.cs`
+
+- [ ] **Step 1: Leggere il file**
+
+ Ci sono 3 endpoint che chiamano `_orchestrator.GenerateReportAsync(isin, showBranding)`:
+ - `GenerateReport` (GET `/api/report?p=...`)
+ - `GenerateReportByIsin` (GET `/api/report/by-isin/{isin}`)
+ - `DownloadReport` (GET `/api/report/download?p=...`)
+
+- [ ] **Step 2: Modificare `GenerateReport`**
+
+ Aggiungere il parametro `dividend` e passarlo all'orchestratore:
+ ```csharp
+ public async Task GenerateReport(
+ [FromQuery(Name = "p")] string? encryptedIsin = null,
+ [FromQuery(Name = "alias")] string? aliasId = null,
+ [FromQuery(Name = "branding")] bool showBranding = false,
+ [FromQuery(Name = "dividend")] bool showDividend = false)
+ ```
+ E nella chiamata:
+ ```csharp
+ return await GenerateAndReturnPdf(isin, showBranding, showDividend);
+ ```
+
+- [ ] **Step 3: Modificare `GenerateReportByIsin`**
+
+ ```csharp
+ public async Task GenerateReportByIsin(
+ string isin,
+ [FromQuery(Name = "branding")] bool showBranding = false,
+ [FromQuery(Name = "dividend")] bool showDividend = false)
+ ```
+ E nella chiamata:
+ ```csharp
+ return await GenerateAndReturnPdf(isin, showBranding, showDividend);
+ ```
+
+- [ ] **Step 4: Modificare `DownloadReport`**
+
+ ```csharp
+ public async Task DownloadReport(
+ [FromQuery(Name = "p")] string? encryptedIsin = null,
+ [FromQuery(Name = "alias")] string? aliasId = null,
+ [FromQuery(Name = "branding")] bool showBranding = false,
+ [FromQuery(Name = "dividend")] bool showDividend = false)
+ ```
+ Nella chiamata interna:
+ ```csharp
+ var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend);
+ ```
+
+- [ ] **Step 5: Aggiornare il metodo privato `GenerateAndReturnPdf`**
+
+ Trovare la firma di `GenerateAndReturnPdf` e aggiungere `bool showDividend = false`, poi passarla a `GenerateReportAsync`:
+ ```csharp
+ private async Task GenerateAndReturnPdf(string isin, bool showBranding = false, bool showDividend = false)
+ {
+ // ...
+ var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend);
+ // ...
+ }
+ ```
+
+- [ ] **Step 6: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: errore su `ReportOrchestrator` (non ancora aggiornato) — normale.
+
+- [ ] **Step 7: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Controllers/ReportController.cs
+ git commit -m "feat: add ?dividend query param to all report endpoints"
+ ```
+
+---
+
+## Task 4: Aggiornare `AnagraficaSectionRenderer` — skip SEZIONE C
+
+**Files:**
+- Modify: `Services/Implementations/AnagraficaSectionRenderer.cs`
+
+- [ ] **Step 1: Leggere il file**
+
+ Trovare il blocco SEZIONE C. Attualmente:
+ ```csharp
+ // ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
+ if (info.Sottostanti.Count > 0)
+ {
+ // Se lo spazio rimanente è meno di 80pt, nuova pagina
+ if (y > PageH - 80f)
+ {
+ PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
+ page = doc.Pages.Add();
+ g = page.Graphics;
+ y = 0f;
+ }
+ DrawSottostanti(g, info.Sottostanti, PageW, y);
+ }
+ ```
+
+- [ ] **Step 2: Wrappare il blocco con la condizione**
+
+ ```csharp
+ // ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
+ if (!data.ShowDividend && info.Sottostanti.Count > 0)
+ {
+ // Se lo spazio rimanente è meno di 80pt, nuova pagina
+ if (y > PageH - 80f)
+ {
+ PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
+ page = doc.Pages.Add();
+ g = page.Graphics;
+ y = 0f;
+ }
+ DrawSottostanti(g, info.Sottostanti, PageW, y);
+ }
+ ```
+
+- [ ] **Step 3: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: stesso errore su `ReportOrchestrator` — normale.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs
+ git commit -m "feat: skip sottostanti table in anagrafica when ShowDividend=true"
+ ```
+
+---
+
+## Task 5: Aggiornare `ExpiredAnagraficaSectionRenderer` — skip SEZIONE C
+
+**Files:**
+- Modify: `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs`
+
+- [ ] **Step 1: Leggere il file**
+
+ Trovare il blocco SEZIONE C (struttura identica ad `AnagraficaSectionRenderer`):
+ ```csharp
+ // ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
+ if (info.Sottostanti.Count > 0)
+ {
+ if (y > PageH - 80f)
+ {
+ PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
+ page = doc.Pages.Add();
+ g = page.Graphics;
+ ...
+ }
+ DrawSottostanti(g, info.Sottostanti, PageW, y);
+ }
+ ```
+
+- [ ] **Step 2: Stessa modifica del Task 4**
+
+ ```csharp
+ // ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
+ if (!data.ShowDividend && info.Sottostanti.Count > 0)
+ {
+ if (y > PageH - 80f)
+ {
+ PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
+ page = doc.Pages.Add();
+ g = page.Graphics;
+ ...
+ }
+ DrawSottostanti(g, info.Sottostanti, PageW, y);
+ }
+ ```
+
+- [ ] **Step 3: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs
+ git commit -m "feat: skip sottostanti table in expired anagrafica when ShowDividend=true"
+ ```
+
+---
+
+## Task 6: Creare `DividendSectionRenderer`
+
+**Files:**
+- Create: `Services/Implementations/DividendSectionRenderer.cs`
+
+- [ ] **Step 1: Creare il file con la struttura base**
+
+ ```csharp
+ using CertReports.Syncfusion.Models;
+ using Syncfusion.Pdf;
+ using Syncfusion.Pdf.Graphics;
+ using Syncfusion.Pdf.Grid;
+ using System.Drawing;
+
+ namespace CertReports.Syncfusion.Services.Implementations;
+
+ ///
+ /// Genera la pagina landscape con la tabella unificata Sottostanti + Dividendi.
+ /// Non implementa IPdfSectionRenderer — iniettato direttamente nell'orchestratore.
+ ///
+ public class DividendSectionRenderer
+ {
+ // Larghezze colonne indicative (totale 742pt) — scalate proporzionalmente a runtime
+ private static readonly float[] ColWidths =
+ [
+ 90f, // Nome
+ 50f, // Strike
+ 50f, // Last
+ 46f, // % Perf.
+ 50f, // Barr.Cap.
+ 46f, // Buf.Cap.
+ 50f, // Trig.CPN
+ 46f, // Buf.CPN
+ 46f, // Trig.AC
+ 54f, // Data Stacco
+ 54f, // Data Pag.
+ 40f, // Importo
+ 40f, // Rend.
+ 40f, // Imp.Fut.
+ 40f, // Rend.Fut.
+ ];
+
+ private static readonly string[] Col2Headers =
+ [
+ "", "Strike", "Last", "% Perf.",
+ "Barr.Cap.", "Buf.Cap.", "Trig.CPN", "Buf.CPN", "Trig.AC",
+ "Data Stacco", "Data Pag.", "Importo", "Rend.", "Imp.Fut.", "Rend.Fut.",
+ ];
+
+ // Indici colonne soggette a colore performance (0-based)
+ private static readonly HashSet PerfCols = [3, 5, 7, 12, 14];
+
+ public PdfDocument Render(CertificateReportData data)
+ {
+ var doc = new PdfDocument();
+ doc.PageSettings.Orientation = PdfPageOrientation.Landscape;
+ doc.PageSettings.Size = PdfPageSize.A4;
+
+ var page = doc.Pages.Add();
+ var g = page.Graphics;
+ var size = page.GetClientSize();
+
+ float w = size.Width - 2 * PdfTheme.PageMargin;
+ float h = size.Height - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
+
+ float x0 = PdfTheme.PageMargin;
+ float y = PdfTheme.PageMargin;
+
+ // ── Titolo ────────────────────────────────────────────────────
+ var titleFont = PdfTheme.TitleFont;
+ g.DrawString("Sottostanti e Dividendi", titleFont, PdfTheme.AccentBlueBrush,
+ new RectangleF(x0, y, w, 20f));
+ y += 20f + 4f;
+
+ // ── Linea separatrice ─────────────────────────────────────────
+ g.DrawLine(PdfTheme.AccentBluePen, new PointF(x0, y), new PointF(x0 + w, y));
+ y += 6f;
+
+ // ── Calcolo scale fattore su larghezza reale ──────────────────
+ float totalNominal = ColWidths.Sum();
+ float scale = w / totalNominal;
+ float[] cw = ColWidths.Select(c => c * scale).ToArray();
+
+ // ── Disegno header a 2 livelli ────────────────────────────────
+ float rh = PdfTheme.RowHeight;
+
+ DrawHeader(g, x0, y, cw, rh);
+
+ y += rh * 2;
+
+ // ── Tabella dati ──────────────────────────────────────────────
+ DrawDataGrid(g, data, x0, y, cw, w);
+
+ // ── Footer ────────────────────────────────────────────────────
+ PdfTheme.DrawFooter(g, w, h, 1, data.ShowBranding);
+
+ return doc;
+ }
+
+ private static void DrawHeader(PdfGraphics g, float x0, float y, float[] cw, float rh)
+ {
+ var headerFont = PdfTheme.TableFont;
+ var whiteBrush = PdfBrushes.White;
+ var accentBrush = PdfTheme.AccentBlueBrush;
+ var darkBlueBrush = new PdfSolidBrush(Color.FromArgb(255, 10, 56, 128)); // #0A3880
+
+ // ── Riga 1: Gruppi ────────────────────────────────────────────
+ // "Nome" (col 0)
+ float cx = x0;
+ DrawHeaderCell(g, cx, y, cw[0], rh, "", accentBrush, whiteBrush, headerFont);
+ cx += cw[0];
+
+ // "SOTTOSTANTE" (cols 1-3)
+ float sottostanteW = cw[1] + cw[2] + cw[3];
+ DrawHeaderCell(g, cx, y, sottostanteW, rh, "SOTTOSTANTE", accentBrush, whiteBrush, headerFont);
+ cx += sottostanteW;
+
+ // "BARRIERE" (cols 4-8)
+ float barriereW = cw[4] + cw[5] + cw[6] + cw[7] + cw[8];
+ DrawHeaderCell(g, cx, y, barriereW, rh, "BARRIERE", accentBrush, whiteBrush, headerFont);
+ cx += barriereW;
+
+ // "DIVIDENDI" (cols 9-14) — blu scuro
+ float dividendiW = cw[9] + cw[10] + cw[11] + cw[12] + cw[13] + cw[14];
+ DrawHeaderCell(g, cx, y, dividendiW, rh, "DIVIDENDI", darkBlueBrush, whiteBrush, headerFont);
+
+ // ── Riga 2: Sottocolonne ──────────────────────────────────────
+ cx = x0;
+ for (int i = 0; i < cw.Length; i++)
+ {
+ var bg = i >= 9 ? darkBlueBrush : accentBrush;
+ DrawHeaderCell(g, cx, y + rh, cw[i], rh, Col2Headers[i], bg, whiteBrush, headerFont);
+ cx += cw[i];
+ }
+
+ // ── Separatore verticale tra col 8 e col 9 ───────────────────
+ float sepX = x0 + cw.Take(9).Sum();
+ var sepPen = new PdfPen(Color.FromArgb(255, 100, 181, 246), 1.5f); // #64B5F6
+ g.DrawLine(sepPen, new PointF(sepX, y), new PointF(sepX, y + rh * 2));
+ }
+
+ private static void DrawHeaderCell(PdfGraphics g, float x, float y, float w, float h,
+ string text, PdfBrush bg, PdfBrush fg, PdfFont font)
+ {
+ g.DrawRectangle(bg, new RectangleF(x, y, w, h));
+ if (!string.IsNullOrEmpty(text))
+ {
+ var fmt = new PdfStringFormat
+ {
+ Alignment = PdfTextAlignment.Center,
+ LineAlignment = PdfVerticalAlignment.Middle,
+ };
+ g.DrawString(text, font, fg, new RectangleF(x + 1, y, w - 2, h), fmt);
+ }
+ }
+
+ private static void DrawDataGrid(PdfGraphics g, CertificateReportData data,
+ float x0, float y, float[] cw, float pageW)
+ {
+ var grid = new PdfGrid();
+ grid.Style.CellPadding = new PdfPaddings(2, 2, 2, 2);
+ grid.Style.Font = PdfTheme.TableFont;
+
+ // 15 colonne
+ grid.Columns.Add(15);
+ for (int i = 0; i < 15; i++)
+ grid.Columns[i].Width = cw[i];
+
+ var sottostanti = data.Info.Sottostanti;
+
+ for (int i = 0; i < sottostanti.Count; i++)
+ {
+ var s = sottostanti[i];
+ var row = grid.Rows.Add();
+
+ row.Cells[0].Value = s.Nome;
+ row.Cells[1].Value = s.Strike;
+ row.Cells[2].Value = s.LastPrice;
+ row.Cells[3].Value = s.Performance ?? "—";
+ row.Cells[4].Value = s.CapitalBarrier ?? "—";
+ row.Cells[5].Value = s.ULCapitalBarrierBuffer ?? "—";
+ row.Cells[6].Value = s.CouponBarrier ?? "—";
+ row.Cells[7].Value = s.ULCouponBarrierBuffer ?? "—";
+ row.Cells[8].Value = s.TriggerAutocall ?? "—";
+ row.Cells[9].Value = s.DividendExDate ?? "—";
+ row.Cells[10].Value = s.DividendPayDate ?? "—";
+ row.Cells[11].Value = s.DividendAmount ?? "—";
+ row.Cells[12].Value = s.DividendYield ?? "—";
+ row.Cells[13].Value = s.DividendFutAmount ?? "—";
+ row.Cells[14].Value = s.DividendFutYield ?? "—";
+
+ // Righe alternate
+ if (i % 2 == 1)
+ {
+ for (int c = 0; c < 15; c++)
+ row.Cells[c].Style.BackgroundBrush = PdfTheme.TableAltRowBrush;
+ }
+
+ // Colore performance (negativi rosso, positivi verde)
+ foreach (int c in PerfCols)
+ {
+ var val = row.Cells[c].Value?.ToString();
+ if (string.IsNullOrEmpty(val) || val == "—") continue;
+
+ // Le stringhe arrivano già formattate dalla SP, es. "-12,34%"
+ bool isNegative = val.TrimStart().StartsWith('-');
+ row.Cells[c].Style.TextBrush = isNegative
+ ? PdfTheme.NegativeRedBrush
+ : PdfTheme.PositiveGreenBrush;
+ }
+
+ // Allineamento centrato per tutte le celle tranne Nome
+ for (int c = 1; c < 15; c++)
+ {
+ row.Cells[c].StringFormat = new PdfStringFormat
+ {
+ Alignment = PdfTextAlignment.Center,
+ LineAlignment = PdfVerticalAlignment.Middle,
+ };
+ }
+ }
+
+ grid.Draw(g, new PointF(x0, y));
+ }
+ }
+ ```
+
+- [ ] **Step 2: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: errore su `ReportOrchestrator` — normale.
+
+- [ ] **Step 3: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Services/Implementations/DividendSectionRenderer.cs
+ git commit -m "feat: add DividendSectionRenderer with landscape two-level header table"
+ ```
+
+---
+
+## Task 7: Registrare `DividendSectionRenderer` in `Program.cs`
+
+**Files:**
+- Modify: `Program.cs`
+
+- [ ] **Step 1: Leggere `Program.cs`**
+
+ Trovare il blocco delle registrazioni renderer. Attualmente c'è:
+ ```csharp
+ builder.Services.AddScoped();
+ ```
+
+- [ ] **Step 2: Aggiungere la registrazione subito sotto**
+
+ ```csharp
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ ```
+
+- [ ] **Step 3: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: solo errore su `ReportOrchestrator` — normale.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Program.cs
+ git commit -m "feat: register DividendSectionRenderer in DI container"
+ ```
+
+---
+
+## Task 8: Aggiornare `ReportOrchestrator`
+
+**Files:**
+- Modify: `Services/Implementations/ReportOrchestrator.cs`
+
+- [ ] **Step 1: Leggere il file completo**
+
+ Leggere l'intero file per avere contesto preciso prima di modificare.
+
+- [ ] **Step 2: Aggiungere il campo e il parametro costruttore**
+
+ Aggiungere dopo `_expiredAnagraficaRenderer`:
+ ```csharp
+ private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer;
+ private readonly DividendSectionRenderer _dividendRenderer;
+ ```
+
+ Nel costruttore aggiungere il parametro dopo `expiredAnagraficaRenderer`:
+ ```csharp
+ ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer,
+ DividendSectionRenderer dividendRenderer)
+ ```
+
+ E l'assegnazione:
+ ```csharp
+ _expiredAnagraficaRenderer = expiredAnagraficaRenderer;
+ _dividendRenderer = dividendRenderer;
+ ```
+
+- [ ] **Step 3: Aggiornare la signature del metodo e le chiavi cache**
+
+ Cambiare la firma:
+ ```csharp
+ public async Task GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false)
+ ```
+
+ Aggiornare le chiavi cache (le 4 combinazioni):
+ ```csharp
+ var dividendSuffix = showDividend ? ":dividend" : "";
+
+ var baseCacheKey = showBranding ? $"{isin}:branded{dividendSuffix}" : $"{isin}{dividendSuffix}";
+ var expiredCacheKey = showBranding ? $"{isin}:expired:branded{dividendSuffix}" : $"{isin}:expired{dividendSuffix}";
+ ```
+
+ Nota: questo produce le 4 chiavi:
+ - `{isin}` — no branding, no dividend
+ - `{isin}:branded` — branding, no dividend
+ - `{isin}:dividend` — no branding, dividend
+ - `{isin}:branded:dividend` — branding + dividend
+ - (+ varianti expired)
+
+- [ ] **Step 4: Impostare `ShowDividend` nel `CertificateReportData`**
+
+ Nel blocco di inizializzazione:
+ ```csharp
+ var reportData = new CertificateReportData
+ {
+ Info = await _dataService.GetCertificateInfoAsync(isin),
+ Eventi = await _dataService.GetCertificateEventsAsync(isin),
+ Scenario = await _dataService.GetScenarioAnalysisAsync(isin),
+ ShowBranding = showBranding,
+ ShowDividend = showDividend,
+ };
+ ```
+
+- [ ] **Step 5: Inserire la sezione dividend nel flusso expired**
+
+ Dopo `pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));`, aggiungere:
+ ```csharp
+ pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));
+
+ if (reportData.ShowDividend)
+ {
+ try
+ {
+ pdfSections.Add(_dividendRenderer.Render(reportData));
+ _logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin);
+ throw;
+ }
+ }
+ ```
+
+- [ ] **Step 6: Inserire la sezione dividend nel flusso attivo (attuale)**
+
+ Il flusso attivo usa un `foreach` su `_sectionRenderers.OrderBy(r => r.Order)`.
+ `AnagraficaSectionRenderer` ha `Order = 1` (Sezione 1).
+ Dobbiamo inserire la pagina dividend subito dopo Anagrafica.
+
+ Sostituire il `foreach` con:
+ ```csharp
+ foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order))
+ {
+ if (renderer.SectionName == "Scenario" && !isScenarioAllowed)
+ {
+ _logger.LogInformation("Sezione Scenario saltata per {Isin}", isin);
+ continue;
+ }
+
+ try
+ {
+ pdfSections.Add(renderer.Render(reportData));
+ _logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Errore nella sezione '{Section}' per {Isin}", renderer.SectionName, isin);
+ throw;
+ }
+
+ // Inserire pagina dividend subito dopo Anagrafica (Sezione 1)
+ if (renderer.SectionName == "Anagrafica" && reportData.ShowDividend)
+ {
+ try
+ {
+ pdfSections.Add(_dividendRenderer.Render(reportData));
+ _logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin);
+ throw;
+ }
+ }
+ }
+ ```
+
+- [ ] **Step 7: Build**
+
+ ```bash
+ dotnet build CertReports.Syncfusion
+ ```
+ Expected: **Build succeeded, 0 errors**.
+
+- [ ] **Step 8: Commit**
+
+ ```bash
+ git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs
+ git commit -m "feat: wire DividendSectionRenderer into orchestrator — both active and expired flows"
+ ```
+
+---
+
+## Task 9: Verifica manuale
+
+Non esistono test automatici nel progetto. Verificare manualmente:
+
+- [ ] **Step 1: Avviare l'applicazione**
+
+ ```bash
+ dotnet run --project CertReports.Syncfusion
+ ```
+
+- [ ] **Step 2: Test baseline — comportamento invariato**
+
+ Aprire nel browser:
+ ```
+ https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO}
+ ```
+ Expected: report normale con tabella Sottostanti visibile in Sezione 1. Nessuna pagina landscape.
+
+- [ ] **Step 3: Test con `?dividend=true`**
+
+ ```
+ https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO}?dividend=true
+ ```
+ Expected:
+ - Sezione 1 (Anagrafica): **nessuna** tabella Sottostanti
+ - Dopo Sezione 1: **pagina landscape** con titolo "Sottostanti e Dividendi"
+ - Header a 2 livelli: "SOTTOSTANTE" (blu) | "BARRIERE" (blu) | "DIVIDENDI" (blu scuro)
+ - Separatore verticale tra colonna Trig.AC e Data Stacco
+ - Righe dati con colori performance su % Perf., Buf.Cap., Buf.CPN, Rend., Rend.Fut.
+ - Colonne dividendo vuote mostrano "—"
+ - Footer con numero pagina
+
+- [ ] **Step 4: Test combinato `?dividend=true&branding=true`**
+
+ Expected: come sopra + footer con "Powered by Smart Roots" su tutte le pagine.
+
+- [ ] **Step 5: Test su certificato expired**
+
+ Usare un ISIN con `Stato != "Quotazione"`. Verificare lo stesso comportamento nei flusso expired.
+
+- [ ] **Step 6: Commit finale**
+
+ ```bash
+ git add -A
+ git commit -m "feat: dividend section complete — landscape page with two-level header"
+ ```
+
+---
+
+## Note implementative
+
+### Gotcha Syncfusion v33 da rispettare
+
+| Problema | Soluzione applicata nel piano |
+|----------|-------------------------------|
+| `Color(r,g,b)` rimosso | `Color.FromArgb(255, r, g, b)` usato ovunque |
+| `grid.Draw()` restituisce `void` | Usato senza assegnazione |
+| `RectangleF` no named args | `new RectangleF(x, y, w, h)` — no named parameters |
+| Namespace conflict | Aggiungere `using Syncfusion.Pdf;` se necessario |
+
+### Dimensioni pagina landscape
+
+A4 landscape in Syncfusion: `Width ≈ 841pt`, `Height ≈ 595pt`.
+Con `PageMargin = 30pt` (verificare in `PdfTheme`), la larghezza utile `w ≈ 781pt`.
+Le colonne vengono scalate proporzionalmente da 742pt a `w` → `scale ≈ 1.05`.
+
+### `SectionName == "Anagrafica"`
+
+Il valore esatto confermato in `AnagraficaSectionRenderer.cs` è `"Anagrafica"` — la stringa di confronto nel Task 8 Step 6 è corretta.
diff --git a/docs/superpowers/plans/2026-05-27-chart-v2.md b/docs/superpowers/plans/2026-05-27-chart-v2.md
new file mode 100644
index 0000000..3863c99
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-chart-v2.md
@@ -0,0 +1,932 @@
+# 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/Nominal*100; UL: Px_close(adj)/Strike*100) |
+
+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**
+
+```csharp
+namespace CertReports.Syncfusion.Models;
+
+///
+/// Metadata di un sottostante (da SP cedlab_Chart_UL1).
+/// Prima riga = worst-of (IsWorstOf = 1).
+///
+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; }
+}
+
+///
+/// Singolo punto di una serie (da SP cedlab_Chart_AllSeriesV2).
+/// IDUnderlyings = 0 → serie del certificato.
+///
+public class ChartSeriesPoint
+{
+ public int IDUnderlyings { get; set; }
+ public DateTime Date { get; set; }
+ public decimal Performance { get; set; }
+}
+
+///
+/// Dati completi per il grafico V2.
+///
+public class ChartDataV2
+{
+ public string Isin { get; set; } = string.Empty;
+
+ ///
+ /// Metadata globale: prima riga di cedlab_Chart_UL1 (il worst-of).
+ /// Contiene NomeCFT, NumPrezziCFT, barriere, trigger — uguali per tutte le righe.
+ ///
+ public ChartUlMetadata GlobalMeta { get; set; } = new();
+
+ /// Tutti i sottostanti (per IsWorstOf, PriceWorst, nomi legenda).
+ public List Underlyings { get; set; } = new();
+
+ /// Tutti i punti di tutte le serie (CTF + UL), ordinati per data.
+ public List SeriesPoints { get; set; } = new();
+}
+```
+
+- [ ] **Step 2: Verifica compilazione**
+
+```powershell
+dotnet build CertReports.Syncfusion
+```
+
+Expected: `Build succeeded. 0 Error(s)`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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**
+
+```csharp
+using CertReports.Syncfusion.Models;
+using Microsoft.Data.SqlClient;
+using System.Data;
+
+namespace CertReports.Syncfusion.Services.Implementations;
+
+///
+/// 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)
+///
+public interface IChartDataServiceV2
+{
+ Task GetChartDataV2Async(string isin);
+}
+
+public class ChartDataServiceV2 : IChartDataServiceV2
+{
+ private readonly string _connectionString;
+ private readonly ILogger _logger;
+
+ public ChartDataServiceV2(IConfiguration config, ILogger logger)
+ {
+ _connectionString = config.GetConnectionString("CertDb")
+ ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
+ _logger = logger;
+ }
+
+ public async Task GetChartDataV2Async(string isin)
+ {
+ await using var conn = new SqlConnection(_connectionString);
+ await conn.OpenAsync();
+
+ // ── 1. Metadata sottostanti (cedlab_Chart_UL1) ─────────────────
+ var underlyings = new List();
+
+ 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**
+
+```powershell
+dotnet build CertReports.Syncfusion
+```
+
+Expected: `Build succeeded. 0 Error(s)`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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`)
+
+```csharp
+using CertReports.Syncfusion.Models;
+using SkiaSharp;
+
+namespace CertReports.Syncfusion.Services.Implementations;
+
+///
+/// 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)
+///
+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
+ // ═══════════════════════════════════════════════════════════════════
+
+ /// Genera il grafico come PNG.
+ 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();
+
+ // ── 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> 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
+ {
+ 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 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**
+
+```powershell
+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**
+
+```bash
+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;`):
+```csharp
+private readonly IChartDataServiceV2 _chartDataServiceV2;
+```
+
+Sostituisci il costruttore esistente:
+```csharp
+// PRIMA:
+public ChartController(IChartDataService chartDataService, ILogger logger)
+{
+ _chartDataService = chartDataService;
+ _logger = logger;
+}
+
+// DOPO:
+public ChartController(
+ IChartDataService chartDataService,
+ IChartDataServiceV2 chartDataServiceV2,
+ ILogger logger)
+{
+ _chartDataService = chartDataService;
+ _chartDataServiceV2 = chartDataServiceV2;
+ _logger = logger;
+}
+```
+
+Aggiungi il nuovo action method subito dopo il `}` di `GenerateChart` e prima di `WrapPngInPdf`:
+
+```csharp
+///
+/// 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.
+///
+[HttpGet("v2/{isin}")]
+public async Task 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();`:
+
+```csharp
+builder.Services.AddScoped();
+```
+
+- [ ] **Step 3: Verifica compilazione**
+
+```powershell
+dotnet build CertReports.Syncfusion
+```
+
+Expected: `Build succeeded. 0 Error(s)`
+
+- [ ] **Step 4: Test manuale — avvia l'API**
+
+```powershell
+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**
+
+```bash
+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.