Aggiornamento docs
This commit is contained in:
932
docs/superpowers/plans/2026-05-27-chart-v2.md
Normal file
932
docs/superpowers/plans/2026-05-27-chart-v2.md
Normal file
@@ -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;
|
||||
|
||||
/// <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**
|
||||
|
||||
```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;
|
||||
|
||||
/// <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**
|
||||
|
||||
```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;
|
||||
|
||||
/// <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**
|
||||
|
||||
```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<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`:
|
||||
|
||||
```csharp
|
||||
/// <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>();`:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddScoped<IChartDataServiceV2, ChartDataServiceV2>();
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
Reference in New Issue
Block a user