fix: null-safe string reader, chart V2 label collision avoidance

- ChartDataServiceV2: aggiunto helper ToStr() per GetString null-safe su
  colonne nullable (Sottostante, NomeCFT); pattern analogo a ToDecimal
- SkiaChartRendererV2: collision avoidance label margine destro — tutti i
  label (barriere, strike, autocall, end-label serie) raccolti in lista,
  ordinati per Y e distribuiti con spacing minimo 13px (push-down +
  clamp-up) prima del disegno
- CLAUDE.md: documentati i due fix e la root cause cedlab_Chart_UL1
  divide-by-zero su Strike=0 (fix DB: NULLIF(cu.Strike,0))

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 10:27:44 +02:00
parent bb86c6ac20
commit c655cdd31e
3 changed files with 61 additions and 17 deletions

View File

@@ -86,6 +86,8 @@ HTTP (ISIN) → ReportController → ReportOrchestrator
| Namespace conflict `CertReports.Syncfusion.Pdf` | Aggiungere `using Syncfusion.Pdf;` esplicito nel file | | Namespace conflict `CertReports.Syncfusion.Pdf` | Aggiungere `using Syncfusion.Pdf;` esplicito nel file |
| SkiaSharp `SKFont` NON è IDisposable | Non usare `using` su `SKFont` — solo `SKPaint`, `SKPath`, `SKSurface`, `SKImage`, `SKData` sono IDisposable | | SkiaSharp `SKFont` NON è IDisposable | Non usare `using` su `SKFont` — solo `SKPaint`, `SKPath`, `SKSurface`, `SKImage`, `SKData` sono IDisposable |
| `SqlDataReader.GetDecimal` su colonna `float` SQL | Usare `Convert.ToDecimal(r.GetValue(ord))` — le SP cedlab_ possono restituire `float` (es. `PriceWorst` da subquery su `Prices.Px_close`) | | `SqlDataReader.GetDecimal` su colonna `float` SQL | Usare `Convert.ToDecimal(r.GetValue(ord))` — le SP cedlab_ possono restituire `float` (es. `PriceWorst` da subquery su `Prices.Px_close`) |
| `SqlDataReader.GetString` su colonna nullable | Usare `ToStr(r, column)` (helper in `ChartDataServiceV2`) che fa `r.IsDBNull` prima — `GetString` su `DBNull` lancia `SqlNullValueException` |
| `cedlab_Chart_UL1`: Divide by zero su `Strike = 0` | La SP divide per `cu.Strike` senza guard nel calcolo `PriceWorstPerc`. Fix applicato nel DB: `/ NULLIF(cu.Strike, 0)`. Colpisce certificati con Strike non impostato in `CertificatesUnderlyings` |
## Grafico V2 (`/api/chart/v2/{isin}`) ## Grafico V2 (`/api/chart/v2/{isin}`)
@@ -126,6 +128,8 @@ Percorsi configurati in `appsettings.json` → `ChartSettings:SavePath` e `Chart
**Note renderer**: CTF non mostrato se `NumPrezziCFT < 30` (mostra solo avviso testo); asse X usa intervalli mensili adattivi (12m/6m/3m/1m in base al range). **Note renderer**: CTF non mostrato se `NumPrezziCFT < 30` (mostra solo avviso testo); asse X usa intervalli mensili adattivi (12m/6m/3m/1m in base al range).
**Label destra — collision avoidance**: i label sul margine destro (barriere, strike, autocall, end-label serie) vengono raccolti in una lista `rightLabels`, ordinati per Y, e disegnati da `DrawRightLabels` con spacing minimo 13px. Due passate: discendente (push-down) + risalente (clamp al bordo area). Questo evita la sovrapposizione quando una serie si accavalla con una linea costante.
## Database ## Database
Tutte le stored procedure sono su `FirstSolutionDB`. I dati tornano **già formattati come stringhe** dalle SP (es. `FORMAT(value,'P2','it-IT')`): i modelli C# usano `string` per questi campi. Solo `NominalValue`, `PrezzoEmissione`, `CpnPagati`, `CpnDaPagare`, `CpnInMemoria` sono `decimal?` perché servono come valori numerici nel rendering. Tutte le stored procedure sono su `FirstSolutionDB`. I dati tornano **già formattati come stringhe** dalle SP (es. `FORMAT(value,'P2','it-IT')`): i modelli C# usano `string` per questi campi. Solo `NominalValue`, `PrezzoEmissione`, `CpnPagati`, `CpnDaPagare`, `CpnInMemoria` sono `decimal?` perché servono come valori numerici nel rendering.

View File

@@ -60,12 +60,12 @@ public class ChartDataServiceV2 : IChartDataServiceV2
BarrieraCoupon = ToDecimal(r, "BarrieraCoupon"), BarrieraCoupon = ToDecimal(r, "BarrieraCoupon"),
BarrieraCapitalePerc = ToDecimal(r, "BarrieraCapitalePerc"), BarrieraCapitalePerc = ToDecimal(r, "BarrieraCapitalePerc"),
BarrieraCapitale = ToDecimal(r, "BarrieraCapitale"), BarrieraCapitale = ToDecimal(r, "BarrieraCapitale"),
Sottostante = r.GetString(r.GetOrdinal("Sottostante")), Sottostante = ToStr(r, "Sottostante"),
IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")), IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")),
PriceWorst = ToDecimal(r, "PriceWorst"), PriceWorst = ToDecimal(r, "PriceWorst"),
PriceWorstPerc = ToDecimal(r, "PriceWorstPerc"), PriceWorstPerc = ToDecimal(r, "PriceWorstPerc"),
NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")), NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")),
NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")), NomeCFT = ToStr(r, "NomeCFT"),
TriggerAutocallPerc = ToDecimal(r, "TriggerAutocallPerc"), TriggerAutocallPerc = ToDecimal(r, "TriggerAutocallPerc"),
AutocallValue = ToDecimal(r, "AutocallValue"), AutocallValue = ToDecimal(r, "AutocallValue"),
}); });
@@ -134,4 +134,10 @@ public class ChartDataServiceV2 : IChartDataServiceV2
if (r.IsDBNull(ord)) return 0m; if (r.IsDBNull(ord)) return 0m;
return Convert.ToDecimal(r.GetValue(ord)); return Convert.ToDecimal(r.GetValue(ord));
} }
private static string ToStr(SqlDataReader r, string column)
{
int ord = r.GetOrdinal(column);
return r.IsDBNull(ord) ? string.Empty : r.GetString(ord);
}
} }

View File

@@ -79,12 +79,13 @@ public static class SkiaChartRendererV2
// ── Linee costanti con label ─────────────────────────────────── // ── Linee costanti con label ───────────────────────────────────
var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>(); var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
var rightLabels = new List<(float Y, string Text, SKColor Color)>();
string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc
? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})" ? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})"
: $"Barriera Capitale {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, DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel); (float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel, rightLabels);
constLegend.Add((bcLabel, CapitaleColor, false, 1.5f)); constLegend.Add((bcLabel, CapitaleColor, false, 1.5f));
if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc
@@ -92,13 +93,13 @@ public static class SkiaChartRendererV2
{ {
string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})"; string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel); (float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel, rightLabels);
constLegend.Add((bkLabel, CouponColor, false, 1.5f)); constLegend.Add((bkLabel, CouponColor, false, 1.5f));
} }
string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})"; string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
100f, StrikeColor, 1.5f, false, strikeLabel); 100f, StrikeColor, 1.5f, false, strikeLabel, rightLabels);
constLegend.Add((strikeLabel, StrikeColor, false, 1.5f)); constLegend.Add((strikeLabel, StrikeColor, false, 1.5f));
bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0 bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
@@ -109,7 +110,7 @@ public static class SkiaChartRendererV2
{ {
string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})"; string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel); (float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel, rightLabels);
constLegend.Add((taLabel, AutocallColor, false, 1.5f)); constLegend.Add((taLabel, AutocallColor, false, 1.5f));
} }
@@ -118,7 +119,7 @@ public static class SkiaChartRendererV2
{ {
string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})"; string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel); (float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel, rightLabels);
} }
// ── Serie ────────────────────────────────────────────────────── // ── Serie ──────────────────────────────────────────────────────
@@ -159,10 +160,12 @@ public static class SkiaChartRendererV2
} }
DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness); DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness);
DrawSeriesEndLabel(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel); DrawSeriesEndLabel(plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel, rightLabels);
seriesLegend.Add((seriesLabel, color, false, thickness)); seriesLegend.Add((seriesLabel, color, false, thickness));
} }
DrawRightLabels(canvas, plotArea, rightLabels);
// Bordo area plot // Bordo area plot
using var borderPaint = new SKPaint using var borderPaint = new SKPaint
{ {
@@ -372,19 +375,17 @@ public static class SkiaChartRendererV2
// Label al termine di una serie (estremo destro) // Label al termine di una serie (estremo destro)
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
private static void DrawSeriesEndLabel(SKCanvas canvas, SKRect area, private static void DrawSeriesEndLabel(SKRect area,
List<ChartSeriesPoint> points, List<ChartSeriesPoint> points,
DateTime minDate, DateTime maxDate, double minY, double maxY, DateTime minDate, DateTime maxDate, double minY, double maxY,
SKColor color, string label) SKColor color, string label,
List<(float Y, string Text, SKColor Color)> rightLabels)
{ {
if (points.Count == 0) return; if (points.Count == 0) return;
var last = points.Last(); var last = points.Last();
float y = ValueToY((double)last.Performance, area, minY, maxY); float y = ValueToY((double)last.Performance, area, minY, maxY);
y = Math.Max(area.Top, Math.Min(area.Bottom, y)); y = Math.Max(area.Top, Math.Min(area.Bottom, y));
rightLabels.Add((y + 4, label, color));
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);
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -393,7 +394,8 @@ public static class SkiaChartRendererV2
private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area, private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area,
double minY, double maxY, double minY, double maxY,
float value, SKColor color, float thickness, bool dashed, string label) float value, SKColor color, float thickness, bool dashed, string label,
List<(float Y, string Text, SKColor Color)> rightLabels)
{ {
float y = ValueToY(value, area, minY, maxY); float y = ValueToY(value, area, minY, maxY);
if (y < area.Top || y > area.Bottom) return; if (y < area.Top || y > area.Bottom) return;
@@ -407,10 +409,42 @@ public static class SkiaChartRendererV2
linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0); linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0);
canvas.DrawLine(area.Left, y, area.Right, y, linePaint); canvas.DrawLine(area.Left, y, area.Right, y, linePaint);
rightLabels.Add((y + 4, label, color));
}
// ═══════════════════════════════════════════════════════════════════
// Label destra — collision avoidance
// ═══════════════════════════════════════════════════════════════════
private static void DrawRightLabels(SKCanvas canvas, SKRect area,
List<(float Y, string Text, SKColor Color)> labels)
{
if (labels.Count == 0) return;
const float minSpacing = 13f;
var items = labels.OrderBy(l => l.Y).ToList();
float[] ys = items.Select(l => l.Y).ToArray();
// Passata discendente: sposta in basso le label troppo vicine
for (int i = 1; i < ys.Length; i++)
if (ys[i] < ys[i - 1] + minSpacing)
ys[i] = ys[i - 1] + minSpacing;
// Passata risalente: se qualcuna è finita sotto il bordo, riporta su
float maxAllowed = area.Bottom + 4f;
for (int i = ys.Length - 1; i >= 0; i--)
{
if (ys[i] > maxAllowed) ys[i] = maxAllowed;
maxAllowed = ys[i] - minSpacing;
}
var font = CreateFont(9.5f); var font = CreateFont(9.5f);
using var textPaint = new SKPaint { Color = color, IsAntialias = true }; for (int i = 0; i < items.Count; i++)
canvas.DrawText(label, area.Right + 5, y + 4, SKTextAlign.Left, font, textPaint); {
using var textPaint = new SKPaint { Color = items[i].Color, IsAntialias = true };
canvas.DrawText(items[i].Text, area.Right + 5, ys[i], SKTextAlign.Left, font, textPaint);
}
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════