Compare commits

..

10 Commits

Author SHA1 Message Date
c9db398336 docs: document GET /api/chart/underlying/{idUnderlyings} endpoint 2026-07-21 10:46:11 +02:00
7b9553ad4e feat: add GET /api/chart/underlying/{idUnderlyings} endpoint 2026-07-21 10:41:12 +02:00
3887404572 feat: add RenderSingleSeriesToPng for standalone underlying chart 2026-07-21 10:37:18 +02:00
a75d1711e6 feat: add UnderlyingChartDataService and DI registration 2026-07-21 10:34:08 +02:00
57f09cec62 feat: add UnderlyingChartData model 2026-07-21 10:31:57 +02:00
fdbf16ad0b fix: use CONCAT for NULL-safe underlying name in cedlab_Chart_UnderlyingOnly 2026-07-21 10:31:15 +02:00
d6f781e34d docs: add cedlab_Chart_UnderlyingOnly SP script 2026-07-21 10:29:03 +02:00
a03a437f1a docs: add implementation plan for standalone underlying chart endpoint
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:36 +02:00
d15af22a2a docs: fix underlying name/ticker column in chart spec
Underlyings.Name + Underlyings.Ticker_bbg, composed as "{Name} (Ticker)".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:23:14 +02:00
27784bcb4f docs: add design spec for standalone underlying-only chart endpoint
Spec for a new /api/chart/underlying/{idUnderlyings} endpoint that
renders a single price-history line for one underlying, independent
of any certificate context (no strike/barriers/legend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:17:39 +02:00
9 changed files with 1053 additions and 0 deletions

View File

@@ -143,6 +143,7 @@ Tutte le stored procedure sono su `FirstSolutionDB`. I dati tornano **già forma
- Tutti gli endpoint report accettano `?natixis=true` (default `false`) per mostrare `info.Nome` nel box Tipologia invece di `info.Categoria` - Tutti gli endpoint report accettano `?natixis=true` (default `false`) per mostrare `info.Nome` nel box Tipologia invece di `info.Categoria`
- `GET /api/chart/{isin}[?format=png|pdf&width=&height=]` — grafico standalone v1 - `GET /api/chart/{isin}[?format=png|pdf&width=&height=]` — grafico standalone v1
- `GET /api/chart/v2/{isin}[?format=png|jpg|jpeg|jpgEnc|pdf&width=&height=]` — grafico standalone v2 (titolo, colori CTF/WorstOf, label linee, legenda in basso) - `GET /api/chart/v2/{isin}[?format=png|jpg|jpeg|jpgEnc|pdf&width=&height=]` — grafico standalone v2 (titolo, colori CTF/WorstOf, label linee, legenda in basso)
- `GET /api/chart/underlying/{idUnderlyings}[?format=png|jpg|jpeg|pdf&width=&height=]` — grafico standalone di un solo sottostante (nessun contesto certificato, no strike/barriere/legenda)
- `GET /health` — health check DB + chart service - `GET /health` — health check DB + chart service
## Footer branding ## Footer branding

View File

@@ -21,17 +21,20 @@ public class ChartController : ControllerBase
{ {
private readonly IChartDataService _chartDataService; private readonly IChartDataService _chartDataService;
private readonly IChartDataServiceV2 _chartDataServiceV2; private readonly IChartDataServiceV2 _chartDataServiceV2;
private readonly IUnderlyingChartDataService _underlyingChartDataService;
private readonly ILogger<ChartController> _logger; private readonly ILogger<ChartController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
public ChartController( public ChartController(
IChartDataService chartDataService, IChartDataService chartDataService,
IChartDataServiceV2 chartDataServiceV2, IChartDataServiceV2 chartDataServiceV2,
IUnderlyingChartDataService underlyingChartDataService,
ILogger<ChartController> logger, ILogger<ChartController> logger,
IConfiguration configuration) IConfiguration configuration)
{ {
_chartDataService = chartDataService; _chartDataService = chartDataService;
_chartDataServiceV2 = chartDataServiceV2; _chartDataServiceV2 = chartDataServiceV2;
_underlyingChartDataService = underlyingChartDataService;
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
} }
@@ -175,6 +178,67 @@ public class ChartController : ControllerBase
} }
} }
/// <summary>
/// Endpoint standalone: grafico con solo la linea prezzo storico di UN sottostante,
/// identificato da IDUnderlyings — nessun contesto certificato (no strike/barriere/legenda).
/// Richiede SP cedlab_Chart_UnderlyingOnly nel DB.
/// Formati supportati: png (default), jpg/jpeg, pdf. Non supporta jpgEnc né ?save=true
/// (nessun alias-certificato applicabile a un sottostante puro).
/// </summary>
[HttpGet("underlying/{idUnderlyings:int}")]
public async Task<IActionResult> GenerateChartUnderlying(
int idUnderlyings,
[FromQuery] int width = 1100,
[FromQuery] int height = 700,
[FromQuery] string format = "png")
{
if (idUnderlyings <= 0)
return BadRequest("IDUnderlyings non valido.");
width = Math.Clamp(width, 400, 2000);
height = Math.Clamp(height, 300, 1500);
try
{
var chartData = await _underlyingChartDataService.GetChartDataAsync(idUnderlyings);
if (chartData == null || chartData.Points.Count == 0)
{
return NotFound(new
{
status = "KO",
message = $"Nessun dato per il grafico del sottostante {idUnderlyings}.",
});
}
bool isJpeg = format.Equals("jpg", StringComparison.OrdinalIgnoreCase)
|| format.Equals("jpeg", StringComparison.OrdinalIgnoreCase);
byte[] imgBytes = SkiaChartRendererV2.RenderSingleSeriesToPng(chartData, width, height, jpeg: isJpeg);
if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
{
byte[] pdfBytes = WrapPngInPdf(imgBytes);
Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.pdf");
return File(pdfBytes, "application/pdf");
}
if (isJpeg)
{
Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.jpg");
return File(imgBytes, "image/jpeg");
}
Response.Headers.Append("Content-Disposition", $"inline; filename=chart_underlying_{idUnderlyings}.png");
return File(imgBytes, "image/png");
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore generazione chart sottostante per IDUnderlyings {IDUnderlyings}", idUnderlyings);
return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico sottostante." });
}
}
/// <summary> /// <summary>
/// Salva il JPEG su disco nei percorsi configurati in appsettings.json (ChartSettings). /// Salva il JPEG su disco nei percorsi configurati in appsettings.json (ChartSettings).
/// format=jpg/jpeg → SavePath/{isin}.jpg /// format=jpg/jpeg → SavePath/{isin}.jpg

View File

@@ -0,0 +1,25 @@
namespace CertReports.Syncfusion.Models;
/// <summary>
/// Singolo punto prezzo per il grafico standalone sottostante
/// (da SP cedlab_Chart_UnderlyingOnly).
/// </summary>
public class UnderlyingChartPoint
{
public DateTime Date { get; set; }
public decimal Px { get; set; }
}
/// <summary>
/// Dati completi per il grafico standalone di un sottostante,
/// senza alcun contesto certificato.
/// </summary>
public class UnderlyingChartData
{
public int IDUnderlyings { get; set; }
/// <summary>Titolo del grafico: "{Name} ({Ticker_bbg})".</summary>
public string Nome { get; set; } = string.Empty;
public List<UnderlyingChartPoint> Points { get; set; } = new();
}

View File

@@ -35,6 +35,7 @@ builder.Services.AddHealthChecks()
builder.Services.AddScoped<ICertificateDataService, CertificateDataService>(); builder.Services.AddScoped<ICertificateDataService, CertificateDataService>();
builder.Services.AddScoped<IChartDataService, ChartDataService>(); builder.Services.AddScoped<IChartDataService, ChartDataService>();
builder.Services.AddScoped<IChartDataServiceV2, ChartDataServiceV2>(); builder.Services.AddScoped<IChartDataServiceV2, ChartDataServiceV2>();
builder.Services.AddScoped<IUnderlyingChartDataService, UnderlyingChartDataService>();
builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>();

View File

@@ -187,6 +187,130 @@ public static class SkiaChartRendererV2
return imgData.ToArray(); return imgData.ToArray();
} }
// ═══════════════════════════════════════════════════════════════════
// Entry point — grafico standalone singolo sottostante (no certificato)
// ═══════════════════════════════════════════════════════════════════
public static byte[] RenderSingleSeriesToPng(UnderlyingChartData data, int width = 1100, int height = 700, bool jpeg = false)
{
using var surface = SKSurface.Create(new SKImageInfo(width, height));
var canvas = surface.Canvas;
canvas.Clear(SKColors.White);
float titleBottom = DrawUnderlyingTitle(canvas, width, data.Nome);
float marginLeft = 70;
float marginRight = 40;
float marginTop = titleBottom + 10;
float marginBottom = 45;
var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom);
var points = data.Points.OrderBy(p => p.Date).ToList();
if (points.Count == 0)
return Array.Empty<byte>();
DateTime minDate = points[0].Date;
DateTime maxDate = points[^1].Date;
double minY = (double)points.Min(p => p.Px);
double maxY = (double)points.Max(p => p.Px);
double range = maxY - minY;
if (range == 0) range = Math.Max(minY * 0.1, 1);
double margin = range * 0.1;
minY -= margin;
maxY += margin;
DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY);
DrawUnderlyingAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY);
DrawUnderlyingSeries(canvas, plotArea, points, minDate, maxDate, minY, maxY);
using var borderPaint = new SKPaint
{
Color = SKColors.Gray, StrokeWidth = 1,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
canvas.DrawRect(plotArea, borderPaint);
using var image = surface.Snapshot();
using var imgData = jpeg
? image.Encode(SKEncodedImageFormat.Jpeg, 90)
: image.Encode(SKEncodedImageFormat.Png, 95);
return imgData.ToArray();
}
private static float DrawUnderlyingTitle(SKCanvas canvas, int width, string nome)
{
float y = 15f;
var boldFont = CreateFont(13f, bold: true);
using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true };
canvas.DrawText(nome, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint);
return y + 22 + 5;
}
private static void DrawUnderlyingAxisLabels(SKCanvas canvas, SKRect area,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
var font = CreateFont(11);
using var paint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
int ySteps = 8;
for (int i = 0; i <= ySteps; i++)
{
double val = maxY - ((maxY - minY) / ySteps) * i;
float y = area.Top + (area.Height / ySteps) * i;
canvas.DrawText($"{val:F2}", area.Left - 55, y + 4, SKTextAlign.Left, font, paint);
}
double totalDays = (maxDate - minDate).TotalDays;
int intervalMonths = XAxisIntervalMonths(minDate, maxDate);
string fmt = totalDays > 365 ? "MMM yy" : "dd/MM/yy";
if (intervalMonths > 0)
{
var d = XAxisStart(minDate, intervalMonths);
while (d <= maxDate)
{
float x = DateToX(d, area, minDate, maxDate);
if (x >= area.Left && x <= area.Right)
canvas.DrawText(d.ToString(fmt), x, area.Bottom + 20, SKTextAlign.Center, font, paint);
d = d.AddMonths(intervalMonths);
}
}
else
{
canvas.DrawText(minDate.ToString("dd/MM/yy"), area.Left, area.Bottom + 20, SKTextAlign.Left, font, paint);
canvas.DrawText(maxDate.ToString("dd/MM/yy"), area.Right, area.Bottom + 20, SKTextAlign.Right, font, paint);
}
}
private static void DrawUnderlyingSeries(SKCanvas canvas, SKRect area,
List<UnderlyingChartPoint> points,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
using var paint = new SKPaint
{
Color = CertColor, StrokeWidth = 2f,
Style = SKPaintStyle.Stroke, IsAntialias = true,
StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round,
};
using var path = new SKPath();
bool first = true;
foreach (var pt in points)
{
float x = DateToX(pt.Date, area, minDate, maxDate);
float y = ValueToY((double)pt.Px, area, minY, maxY);
y = Math.Max(area.Top, Math.Min(area.Bottom, y));
if (first) { path.MoveTo(x, y); first = false; }
else path.LineTo(x, y);
}
canvas.Save();
canvas.ClipRect(area);
canvas.DrawPath(path, paint);
canvas.Restore();
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Titolo // Titolo
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,72 @@
using CertReports.Syncfusion.Models;
using Microsoft.Data.SqlClient;
using System.Data;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Recupera i dati per il grafico standalone di un singolo sottostante
/// (nessun contesto certificato). SP utilizzata: cedlab_Chart_UnderlyingOnly.
/// </summary>
public interface IUnderlyingChartDataService
{
Task<UnderlyingChartData?> GetChartDataAsync(int idUnderlyings);
}
public class UnderlyingChartDataService : IUnderlyingChartDataService
{
private readonly string _connectionString;
private readonly ILogger<UnderlyingChartDataService> _logger;
public UnderlyingChartDataService(IConfiguration config, ILogger<UnderlyingChartDataService> logger)
{
_connectionString = config.GetConnectionString("CertDb")
?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
_logger = logger;
}
public async Task<UnderlyingChartData?> GetChartDataAsync(int idUnderlyings)
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();
await using var cmd = new SqlCommand("cedlab_Chart_UnderlyingOnly", conn)
{ CommandType = CommandType.StoredProcedure };
cmd.Parameters.AddWithValue("@IDUnderlyings", idUnderlyings);
var result = new UnderlyingChartData { IDUnderlyings = idUnderlyings };
await using var r = await cmd.ExecuteReaderAsync();
while (await r.ReadAsync())
{
if (result.Points.Count == 0)
result.Nome = ToStr(r, "Nome");
result.Points.Add(new UnderlyingChartPoint
{
Date = r.GetDateTime(r.GetOrdinal("Px_date")),
Px = Convert.ToDecimal(r.GetValue(r.GetOrdinal("Px"))),
});
}
if (result.Points.Count == 0)
{
_logger.LogWarning(
"Nessun prezzo trovato per il sottostante {IDUnderlyings} (non trovato, cancellato, sospeso, o senza storico prezzi)",
idUnderlyings);
return null;
}
_logger.LogInformation(
"Dati grafico sottostante caricati per {IDUnderlyings}: {Points} punti",
idUnderlyings, result.Points.Count);
return result;
}
private static string ToStr(SqlDataReader r, string column)
{
int ord = r.GetOrdinal(column);
return r.IsDBNull(ord) ? string.Empty : r.GetString(ord);
}
}

View File

@@ -0,0 +1,52 @@
USE [FirstSolutionDB]
GO
-- ============================================================
-- SP: cedlab_Chart_UnderlyingOnly
-- Restituisce lo storico prezzi di UN SOLO sottostante, senza
-- alcun contesto certificato (nessuno strike/barriera/trigger).
-- Usata da UnderlyingChartDataService per il grafico standalone
-- sottostante.
--
-- Output (Px_date ASC, TOP 350 più recenti):
-- Nome VARCHAR -- "{Name} ({Ticker_bbg})", uguale su ogni riga
-- Px_date DATE
-- Px DECIMAL -- Px_close o Px_closeadj in base ad AdjustedPrices
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[cedlab_Chart_UnderlyingOnly]
@IDUnderlyings INT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @AdjustedPrices BIT;
SELECT @AdjustedPrices = ISNULL(AdjustedPrices, 0)
FROM dbo.Underlyings
WHERE IDUnderlyings = @IDUnderlyings
AND deleted = 0
AND sospeso = 0;
IF @AdjustedPrices IS NULL RETURN; -- non trovato / cancellato / sospeso
;WITH Ranked AS
(
SELECT
p.Px_date,
CASE WHEN @AdjustedPrices = 0 THEN p.Px_close ELSE p.Px_closeadj END AS Px,
ROW_NUMBER() OVER (ORDER BY p.Px_date DESC) AS rn
FROM dbo.Prices p
WHERE p.UnderlyingsID = @IDUnderlyings
AND (p.Px_low IS NOT NULL OR p.Px_high IS NOT NULL OR
p.Px_open IS NOT NULL OR p.Px_close IS NOT NULL)
)
SELECT
CONCAT(u.Name, ' (', u.Ticker_bbg, ')') AS Nome,
r.Px_date,
r.Px
FROM Ranked r
CROSS JOIN (SELECT TOP 1 * FROM dbo.Underlyings WHERE IDUnderlyings = @IDUnderlyings) u
WHERE r.rn <= 350
ORDER BY r.Px_date ASC;
END
GO

View File

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

View File

@@ -0,0 +1,137 @@
# Design: grafico standalone per singolo sottostante (senza certificato)
**Data**: 2026-07-21
**Stato**: approvato, in attesa di implementazione
## Obiettivo
Aggiungere un endpoint che generi un grafico semplificato mostrando **solo l'andamento storico prezzo** di un sottostante, identificato da `IDUnderlyings` — senza passare da un ISIN certificato e senza alcun riferimento a strike/barriere/trigger (che esistono solo nel contesto di un certificato).
Caso d'uso: preview rapida dell'andamento di un sottostante indipendente da qualunque certificato collegato.
## Architettura
Stessa pipeline di `/api/chart/{isin}` e `/api/chart/v2/{isin}`, ma senza contesto certificato:
```
GET /api/chart/underlying/{idUnderlyings}?width=&height=&format=
→ ChartController.GenerateChartUnderlying(idUnderlyings, width, height, format)
→ IUnderlyingChartDataService.GetChartDataAsync(idUnderlyings)
→ SP cedlab_Chart_UnderlyingOnly @IDUnderlyings
→ SkiaChartRendererV2 (nuovo metodo per serie singola)
→ PNG/JPEG bytes → risposta inline, oppure wrap in PDF (riuso WrapPngInPdf esistente)
```
## Componenti nuovi
### 1. Modello — `Models/UnderlyingChartModels.cs`
```csharp
public class UnderlyingChartData
{
public int IDUnderlyings { get; set; }
public string Nome { get; set; } = string.Empty; // ticker/nome per il titolo
public List<UnderlyingChartPoint> Points { get; set; } = new();
}
public class UnderlyingChartPoint
{
public DateTime Date { get; set; }
public decimal Px { get; set; }
}
```
### 2. Servizio dati — `Services/Implementations/UnderlyingChartDataService.cs` + `IUnderlyingChartDataService`
- Chiama la SP `cedlab_Chart_UnderlyingOnly @IDUnderlyings`.
- Popola `UnderlyingChartData` con i punti prezzo grezzi (non normalizzati a %, a differenza di v2 che divide per Strike/Nominal — qui non c'è contesto certificato per farlo).
- Registrato in `Program.cs` come `AddScoped<IUnderlyingChartDataService, UnderlyingChartDataService>()`.
### 3. Stored Procedure — `docs/sql/cedlab_Chart_UnderlyingOnly.sql`
```sql
CREATE OR ALTER PROCEDURE [dbo].[cedlab_Chart_UnderlyingOnly]
@IDUnderlyings INT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @AdjustedPrices BIT;
SELECT @AdjustedPrices = ISNULL(AdjustedPrices, 0)
FROM dbo.Underlyings
WHERE IDUnderlyings = @IDUnderlyings
AND deleted = 0
AND sospeso = 0;
IF @AdjustedPrices IS NULL RETURN; -- non trovato / cancellato / sospeso
;WITH Ranked AS
(
SELECT
p.Px_date,
CASE WHEN @AdjustedPrices = 0 THEN p.Px_close ELSE p.Px_closeadj END AS Px,
ROW_NUMBER() OVER (ORDER BY p.Px_date DESC) AS rn
FROM dbo.Prices p
WHERE p.UnderlyingsID = @IDUnderlyings
AND (p.Px_low IS NOT NULL OR p.Px_high IS NOT NULL OR
p.Px_open IS NOT NULL OR p.Px_close IS NOT NULL)
)
SELECT
u.Name + ' (' + u.Ticker_bbg + ')' AS Nome,
r.Px_date,
r.Px
FROM Ranked r
CROSS JOIN (SELECT TOP 1 * FROM dbo.Underlyings WHERE IDUnderlyings = @IDUnderlyings) u
WHERE r.rn <= 350
ORDER BY r.Px_date ASC;
END
GO
```
`Name` = nome sottostante, `Ticker_bbg` = ticker Bloomberg (entrambi in `dbo.Underlyings`). Titolo composto: `{Name} ({Ticker_bbg})`.
### 4. Renderer — nuovo metodo in `SkiaChartRendererV2`
- Firma indicativa: `RenderSingleSeriesToPng(UnderlyingChartData data, int width, int height, bool jpeg)`.
- Riusa griglia, assi X con intervalli mensili adattivi (12m/6m/3m/1m) e stile generale di V2.
- Una sola linea (colore nero o `AccentBlue`, spessore 2px).
- Titolo in alto = `data.Nome`.
- **Nessuna legenda** (una sola serie non la richiede).
- Nessuna barriera/strike/trigger disegnati (non esiste contesto certificato).
### 5. Controller — nuova route in `ChartController.cs` esistente
```
[HttpGet("underlying/{idUnderlyings}")]
public async Task<IActionResult> GenerateChartUnderlying(
int idUnderlyings,
[FromQuery] int width = 1100,
[FromQuery] int height = 700,
[FromQuery] string format = "png")
```
- `idUnderlyings <= 0``400 BadRequest`.
- Nessun punto prezzo trovato → `404 NotFound` con messaggio esplicativo.
- Formati supportati: `png` (default), `jpg`/`jpeg`, `pdf` (via `WrapPngInPdf` esistente, riuso invariato).
- **Non supportato**: `jpgEnc` e `?save=true` — dipendono dall'alias-certificato (`rpt_CertificatesChartsAlias`) che non esiste per un sottostante puro senza contesto certificato.
- Eccezioni → `500` loggato, stesso pattern try/catch di v1/v2.
## Error handling
| Caso | Risposta |
|------|----------|
| `idUnderlyings <= 0` | 400 |
| Sottostante non trovato / `deleted=1` / `sospeso=1` | 404 |
| Nessun prezzo disponibile | 404 |
| Eccezione runtime | 500 (loggato) |
## Cache
Nessuna cache introdotta per questo endpoint — coerente con `/api/chart/{isin}` e `/api/chart/v2/{isin}`, che non cachano l'immagine (solo il PDF del report principale usa cache). Fuori scope.
## Fuori scope
- Parametro `?save=true` (nessun path di salvataggio dedicato per sottostanti puri).
- Formato `jpgEnc` (nessun alias-certificato applicabile).
- Barriere/strike/trigger/scenari — richiedono contesto certificato, non disponibile con solo `IDUnderlyings`.
- Normalizzazione a percentuale (richiede uno strike/nominal di riferimento, assente qui).