Compare commits
9 Commits
a3f6d6000c
...
0dd74b0d59
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dd74b0d59 | |||
| 679f9e4528 | |||
| e4728cf79e | |||
| a866a2f7d5 | |||
| 44e2098584 | |||
| 0caccc72d5 | |||
| 494443ede3 | |||
| 5d67ae3463 | |||
| 704d634940 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Windows artifacts
|
||||||
|
nul
|
||||||
|
|
||||||
# Visual Studio
|
# Visual Studio
|
||||||
.vs/
|
.vs/
|
||||||
*.user
|
*.user
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ namespace CertReports.Syncfusion.Controllers;
|
|||||||
public class ChartController : ControllerBase
|
public class ChartController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IChartDataService _chartDataService;
|
private readonly IChartDataService _chartDataService;
|
||||||
|
private readonly IChartDataServiceV2 _chartDataServiceV2;
|
||||||
private readonly ILogger<ChartController> _logger;
|
private readonly ILogger<ChartController> _logger;
|
||||||
|
|
||||||
public ChartController(IChartDataService chartDataService, ILogger<ChartController> logger)
|
public ChartController(
|
||||||
|
IChartDataService chartDataService,
|
||||||
|
IChartDataServiceV2 chartDataServiceV2,
|
||||||
|
ILogger<ChartController> logger)
|
||||||
{
|
{
|
||||||
_chartDataService = chartDataService;
|
_chartDataService = chartDataService;
|
||||||
|
_chartDataServiceV2 = chartDataServiceV2;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +82,55 @@ public class ChartController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] WrapPngInPdf(byte[] pngBytes)
|
private static byte[] WrapPngInPdf(byte[] pngBytes)
|
||||||
{
|
{
|
||||||
var doc = new PdfDocument();
|
var doc = new PdfDocument();
|
||||||
|
|||||||
56
CertReports.Syncfusion/Models/ChartModelsV2.cs
Normal file
56
CertReports.Syncfusion/Models/ChartModelsV2.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ builder.Services.AddHealthChecks()
|
|||||||
// Registra i servizi applicativi
|
// Registra i servizi applicativi
|
||||||
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<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>();
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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) ─────────────────
|
||||||
|
// cedlab_Chart_UL1 già restituisce tutti i campi necessari per V2
|
||||||
|
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 = ToDecimal(r, "Strike"),
|
||||||
|
BarrieraCouponPerc = ToDecimal(r, "BarrieraCouponPerc"),
|
||||||
|
BarrieraCoupon = ToDecimal(r, "BarrieraCoupon"),
|
||||||
|
BarrieraCapitalePerc = ToDecimal(r, "BarrieraCapitalePerc"),
|
||||||
|
BarrieraCapitale = ToDecimal(r, "BarrieraCapitale"),
|
||||||
|
Sottostante = r.GetString(r.GetOrdinal("Sottostante")),
|
||||||
|
IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")),
|
||||||
|
PriceWorst = ToDecimal(r, "PriceWorst"),
|
||||||
|
PriceWorstPerc = ToDecimal(r, "PriceWorstPerc"),
|
||||||
|
NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")),
|
||||||
|
NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")),
|
||||||
|
TriggerAutocallPerc = ToDecimal(r, "TriggerAutocallPerc"),
|
||||||
|
AutocallValue = ToDecimal(r, "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 = ToDecimal(r, "Performance"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Dati grafico V2 caricati per {Isin}: {UlCount} sottostanti, {Points} punti totali",
|
||||||
|
isin, underlyings.Count, result.SeriesPoints.Count);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legge un campo numerico come decimal indipendentemente dal tipo SQL
|
||||||
|
/// (decimal, float, real, int). Necessario perché cedlab_Chart_UL1 ritorna
|
||||||
|
/// alcuni campi come float (es. PriceWorst dalla subquery su Prices.Px_close).
|
||||||
|
/// </summary>
|
||||||
|
private static decimal ToDecimal(SqlDataReader r, string column)
|
||||||
|
{
|
||||||
|
int ord = r.GetOrdinal(column);
|
||||||
|
if (r.IsDBNull(ord)) return 0m;
|
||||||
|
return Convert.ToDecimal(r.GetValue(ord));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
using CertReports.Syncfusion.Models;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace CertReports.Syncfusion.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renderer grafico V2 con SkiaSharp.
|
||||||
|
///
|
||||||
|
/// Miglioramenti rispetto a v1:
|
||||||
|
/// - Titolo in cima (NomeCFT + avviso se meno di 30 prezzi)
|
||||||
|
/// - CTF in rosso (#CC0000, 2.5px), WorstOf in blu (#1565C0, 2px), altri in grigio (1px)
|
||||||
|
/// - 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 ────────────────────────────────────────────────────────
|
||||||
|
private static readonly SKColor CertColor = new(204, 0, 0);
|
||||||
|
private static readonly SKColor WorstOfColor = new(21, 101, 192);
|
||||||
|
private static readonly SKColor StrikeColor = new(46, 125, 50);
|
||||||
|
private static readonly SKColor CapitaleColor = new(204, 0, 0);
|
||||||
|
private static readonly SKColor CouponColor = new(128, 0, 128);
|
||||||
|
private static readonly SKColor AutocallColor = new(230, 81, 0);
|
||||||
|
private static readonly SKColor PrezzoWorstColor = new(21, 101, 192);
|
||||||
|
private static readonly SKColor TitleColor = new(21, 101, 192);
|
||||||
|
|
||||||
|
private static readonly SKColor[] OtherUlColors =
|
||||||
|
{
|
||||||
|
new(120, 120, 120),
|
||||||
|
new(160, 160, 160),
|
||||||
|
new(90, 90, 90),
|
||||||
|
new(140, 140, 140),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Font helper ───────────────────────────────────────────────────
|
||||||
|
// NOTE: SKFont is NOT IDisposable in this SkiaSharp version. Do NOT use 'using'.
|
||||||
|
private static SKFont CreateFont(float size, bool bold = false) =>
|
||||||
|
new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Entry point
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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 in cima
|
||||||
|
float titleBottom = DrawTitle(canvas, width, data);
|
||||||
|
|
||||||
|
// Margini area plot
|
||||||
|
float marginLeft = 70;
|
||||||
|
float marginRight = 210;
|
||||||
|
float marginTop = titleBottom + 10;
|
||||||
|
float marginBottom = 95;
|
||||||
|
|
||||||
|
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
|
||||||
|
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)>();
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (data.GlobalMeta.NumPrezziCFT < 30)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var constants = new List<double> { 100.0, (double)data.GlobalMeta.BarrieraCapitalePerc };
|
||||||
|
if (data.GlobalMeta.BarrieraCouponPerc > 0)
|
||||||
|
constants.Add((double)data.GlobalMeta.BarrieraCouponPerc);
|
||||||
|
|
||||||
|
bool hasAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
|
||||||
|
&& data.GlobalMeta.TriggerAutocallPerc != 100
|
||||||
|
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
|
||||||
|
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
|
||||||
|
if (hasAutocall)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
double range = maxY - minY;
|
||||||
|
if (range == 0) range = 10;
|
||||||
|
double margin = range * 0.1;
|
||||||
|
minY -= margin;
|
||||||
|
maxY += margin;
|
||||||
|
|
||||||
|
return (minDate, maxDate, minY, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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;
|
||||||
|
float x = plotArea.Left;
|
||||||
|
const float lineW = 22;
|
||||||
|
const float gap = 6;
|
||||||
|
const float itemGap = 14;
|
||||||
|
const float rowHeight = 19;
|
||||||
|
|
||||||
|
var font = CreateFont(10f);
|
||||||
|
|
||||||
|
foreach (var (name, color, dashed, thickness) in items)
|
||||||
|
{
|
||||||
|
float textW = font.MeasureText(name);
|
||||||
|
float itemW = lineW + gap + textW + itemGap;
|
||||||
|
|
||||||
|
if (x + itemW > totalWidth - plotArea.Left && x > plotArea.Left)
|
||||||
|
{
|
||||||
|
x = plotArea.Left;
|
||||||
|
legendY += rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
float midY = legendY + rowHeight / 2f - 2;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
docs/sql/cedlab_Chart_AllSeriesV2.sql
Normal file
114
docs/sql/cedlab_Chart_AllSeriesV2.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
USE [FirstSolutionDB]
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SP: cedlab_Chart_AllSeriesV2
|
||||||
|
-- Restituisce tutte le serie (CTF + tutti UL) in un unico round-trip.
|
||||||
|
-- Usata da ChartDataServiceV2 per il grafico V2.
|
||||||
|
--
|
||||||
|
-- Logica basata su:
|
||||||
|
-- - cedlab_Chart_DailyUL1: stessa formula performance UL, stessi filtri
|
||||||
|
-- - cedlab_Chart_UL1: Strike da CertificatesUnderlyings, StartDate = MIN(Prices.Px_date)
|
||||||
|
--
|
||||||
|
-- Output (IDUnderlyings ASC, Px_date ASC):
|
||||||
|
-- IDUnderlyings INT -- 0 = CTF, altrimenti UnderlyingsID
|
||||||
|
-- Px_date DATE
|
||||||
|
-- Performance DECIMAL -- % su strike/nominal
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE [dbo].[cedlab_Chart_AllSeriesV2]
|
||||||
|
@isin VARCHAR(15)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @IDCertificates INT;
|
||||||
|
DECLARE @Nominal DECIMAL(18, 6);
|
||||||
|
DECLARE @StartDate DATE;
|
||||||
|
|
||||||
|
SELECT TOP 1
|
||||||
|
@IDCertificates = c.IDCertificates,
|
||||||
|
@Nominal = c.Nominal_amount
|
||||||
|
FROM dbo.Certificates c
|
||||||
|
WHERE c.ISIN = @isin;
|
||||||
|
|
||||||
|
IF @IDCertificates IS NULL RETURN;
|
||||||
|
|
||||||
|
-- StartDate = prima data di prezzi EOD del certificato
|
||||||
|
-- (identica a cedlab_Chart_UL1: MIN(p.Px_date) con join su Prices)
|
||||||
|
SELECT @StartDate = MIN(p.Px_date)
|
||||||
|
FROM dbo.Prices p
|
||||||
|
WHERE p.CertificatesID = @IDCertificates;
|
||||||
|
|
||||||
|
-- ── CTE: TOP 350 per-serie tramite ROW_NUMBER DESC, poi reinverte ASC ──
|
||||||
|
WITH AllSeriesRaw AS
|
||||||
|
(
|
||||||
|
-- ── Serie CTF (IDUnderlyings = 0) ────────────────────────────────
|
||||||
|
-- Performance = PX_LAST_EOD / Nominal_amount * 100
|
||||||
|
SELECT
|
||||||
|
0 AS IDUnderlyings,
|
||||||
|
pc.Px_date,
|
||||||
|
CONVERT(DECIMAL(18, 4),
|
||||||
|
pc.PX_LAST_EOD / NULLIF(@Nominal, 0) * 100
|
||||||
|
) AS Performance,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY 0
|
||||||
|
ORDER BY pc.Px_date DESC
|
||||||
|
) AS rn
|
||||||
|
FROM dbo.Prices pc
|
||||||
|
WHERE pc.CertificatesID = @IDCertificates
|
||||||
|
AND pc.PX_LAST_EOD IS NOT NULL
|
||||||
|
AND pc.PX_LAST_EOD <> 0
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- ── Serie UL (tutti i sottostanti del certificato) ────────────────
|
||||||
|
-- Identica a cedlab_Chart_DailyUL1 per ogni UL attivo.
|
||||||
|
-- Strike da CertificatesUnderlyings (come in cedlab_Chart_UL1).
|
||||||
|
-- Date allineate al CTF (INNER JOIN su Prices per CertificatesID).
|
||||||
|
SELECT
|
||||||
|
pu.UnderlyingsID AS IDUnderlyings,
|
||||||
|
pu.Px_date,
|
||||||
|
CONVERT(DECIMAL(18, 4),
|
||||||
|
CASE
|
||||||
|
WHEN ISNULL(u.AdjustedPrices, 0) = 0 THEN pu.Px_close
|
||||||
|
ELSE pu.Px_closeadj
|
||||||
|
END
|
||||||
|
/ NULLIF(cu.Strike, 0) * 100
|
||||||
|
) AS Performance,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY pu.UnderlyingsID
|
||||||
|
ORDER BY pu.Px_date DESC
|
||||||
|
) AS rn
|
||||||
|
FROM dbo.Prices pu
|
||||||
|
LEFT JOIN dbo.Underlyings u
|
||||||
|
ON u.IDUnderlyings = pu.UnderlyingsID
|
||||||
|
INNER JOIN dbo.CertificatesUnderlyings cu
|
||||||
|
ON cu.UnderlyingsID = pu.UnderlyingsID
|
||||||
|
AND cu.CertificatesID = @IDCertificates
|
||||||
|
-- Allinea date UL al CTF (solo date con prezzo CTF valido)
|
||||||
|
INNER JOIN dbo.Prices pc
|
||||||
|
ON pu.Px_date = pc.Px_date
|
||||||
|
AND pc.CertificatesID = @IDCertificates
|
||||||
|
AND pc.PX_LAST_EOD IS NOT NULL
|
||||||
|
AND pc.PX_LAST_EOD <> 0
|
||||||
|
WHERE cu.deleted = 0
|
||||||
|
AND u.deleted = 0
|
||||||
|
AND u.sospeso = 0
|
||||||
|
AND (
|
||||||
|
pu.px_low IS NOT NULL OR pu.Px_high IS NOT NULL OR
|
||||||
|
pu.Px_open IS NOT NULL OR pu.Px_close IS NOT NULL
|
||||||
|
)
|
||||||
|
AND pu.px_date >= @StartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
IDUnderlyings,
|
||||||
|
Px_date,
|
||||||
|
Performance
|
||||||
|
FROM AllSeriesRaw
|
||||||
|
WHERE rn <= 350
|
||||||
|
ORDER BY IDUnderlyings ASC, Px_date ASC;
|
||||||
|
|
||||||
|
END
|
||||||
|
GO
|
||||||
Reference in New Issue
Block a user