391 lines
14 KiB
C#
391 lines
14 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FinlyticAssets.Models;
|
|
using FinlyticAssets.Util;
|
|
using FinlyticBackend.Database;
|
|
using FinlyticBackend.Util;
|
|
using FinlyticCore.Dtos;
|
|
using FinlyticCore.Dtos.Assets;
|
|
using FinlyticCore.Dtos.Fundamentals;
|
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
using FinlyticCore.Models.Assets;
|
|
using FinlyticCore.Util;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace FinlyticBackend.Controllers;
|
|
|
|
/// <summary>
|
|
/// DTO for asset search results (AOT-compliant).
|
|
/// </summary>
|
|
public record AssetSearchResultDto(
|
|
[property: JsonPropertyName("symbol")] string Symbol,
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("isin")] string Isin,
|
|
[property: JsonPropertyName("image")] string Image
|
|
);
|
|
|
|
/// <summary>
|
|
/// DTO for discovery assets (AOT-compliant).
|
|
/// </summary>
|
|
public record DiscoveryAssetResponseDto(
|
|
[property: JsonPropertyName("isin")] string Isin,
|
|
[property: JsonPropertyName("symbol")] string Symbol,
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("type")] string Type,
|
|
[property: JsonPropertyName("category")] string Category,
|
|
[property: JsonPropertyName("image")] string Image,
|
|
[property: JsonPropertyName("tags")] List<string> Tags
|
|
);
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/v1/assets")]
|
|
public class AssetsController : ControllerBase
|
|
{
|
|
private readonly WebMqttClient _mqttClient;
|
|
private readonly BackendDbContext _dbContext;
|
|
private readonly ILogger<AssetsController> _logger;
|
|
|
|
private static List<AssetIndex>? _cachedIndexAssets = null;
|
|
|
|
private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes("""
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
|
<rect width="100" height="100" rx="30" fill="#1E293B"/>
|
|
<path d="M 30 65 L 45 45 L 60 55 L 75 35" fill="none" stroke="#10B981" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<circle cx="75" cy="35" r="5" fill="#06B6D4"/>
|
|
</svg>
|
|
""");
|
|
|
|
public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger<AssetsController> logger)
|
|
{
|
|
_mqttClient = mqttClient;
|
|
_dbContext = dbContext;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lädt den AssetIndex direkt aus der gemounteten index.json.
|
|
/// </summary>
|
|
public static List<AssetIndex> LoadAssetsFromIndexJson()
|
|
{
|
|
if (_cachedIndexAssets != null && _cachedIndexAssets.Count > 0)
|
|
{
|
|
return _cachedIndexAssets;
|
|
}
|
|
|
|
var filePath = Path.Combine(Volumes.IndexRelativePath, "index.json");
|
|
|
|
if (System.IO.File.Exists(filePath))
|
|
{
|
|
try
|
|
{
|
|
string json = System.IO.File.ReadAllText(filePath);
|
|
var assets = JsonSerializer.Deserialize<List<AssetIndex>>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex);
|
|
if (assets != null && assets.Count > 0)
|
|
{
|
|
_cachedIndexAssets = assets;
|
|
return assets;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Durchsucht Assets im Index.
|
|
/// </summary>
|
|
[HttpGet("search")]
|
|
public IActionResult SearchAssets([FromQuery] string q = "")
|
|
{
|
|
string query = q?.Trim() ?? "";
|
|
|
|
var allAssets = LoadAssetsFromIndexJson();
|
|
|
|
var localMatches = !string.IsNullOrWhiteSpace(query)
|
|
? allAssets.Where(a =>
|
|
(!string.IsNullOrEmpty(a.Name) && a.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
|
|
(!string.IsNullOrEmpty(a.Isin) && a.Isin.Contains(query, StringComparison.OrdinalIgnoreCase))
|
|
).ToList()
|
|
: allAssets.Take(50).ToList();
|
|
|
|
var results = localMatches.Select(a => new AssetSearchResultDto(
|
|
Symbol: a.Isin,
|
|
Name: a.Name,
|
|
Isin: a.Isin,
|
|
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}"
|
|
)).Take(50).ToList();
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovery-Endpunkt via RPC mit Fallback auf die index.json.
|
|
/// </summary>
|
|
[HttpGet("discovery")]
|
|
public async Task<IActionResult> GetDiscoveryAssets([FromQuery] int limit = 15)
|
|
{
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDiscoveryAssetsRequest>(
|
|
"assets_GetDiscovery",
|
|
new GetDiscoveryAssetsRequest(limit),
|
|
TimeSpan.FromSeconds(3)
|
|
);
|
|
|
|
if (rpcResult != null && rpcResult.Count > 0)
|
|
{
|
|
var formatted = rpcResult.Select(a => new DiscoveryAssetResponseDto(
|
|
Isin: a.Isin,
|
|
Symbol: a.Isin,
|
|
Name: a.Name,
|
|
Type: a.Type,
|
|
Category: a.InstrumentCategory,
|
|
Image: !string.IsNullOrWhiteSpace(a.ImageId) ? a.ImageId : $"/api/v1/assets/logo/{a.Isin}",
|
|
Tags: a.Tags?.Select(t => t.Name).ToList() ?? new List<string>()
|
|
)).ToList();
|
|
|
|
return Ok(formatted);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[AssetsController] RPC discovery fehlgeschlagen. Fallback auf index.json.");
|
|
}
|
|
|
|
var allAssets = LoadAssetsFromIndexJson();
|
|
var fallback = allAssets.Take(limit).Select(a => new DiscoveryAssetResponseDto(
|
|
Isin: a.Isin,
|
|
Symbol: a.Isin,
|
|
Name: a.Name,
|
|
Type: "stock",
|
|
Category: "Stock",
|
|
Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}",
|
|
Tags: new List<string>()
|
|
)).ToList();
|
|
|
|
return Ok(fallback);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest Fundamentaldaten per ISIN.
|
|
/// </summary>
|
|
[HttpGet("{isin}/fundamentals")]
|
|
public async Task<IActionResult> GetFundamentals(
|
|
[FromRoute] string isin,
|
|
[FromQuery] string? ticker = null,
|
|
[FromQuery] bool forceRefresh = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
string cleanIsin = isin.Trim().ToUpperInvariant();
|
|
string? cleanTicker = ticker?.Trim().ToUpperInvariant();
|
|
|
|
if (string.IsNullOrWhiteSpace(cleanIsin))
|
|
{
|
|
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var requestDto = new IsinRequest(
|
|
Isin: cleanIsin,
|
|
Ticker: cleanTicker,
|
|
ForceRefresh: forceRefresh
|
|
);
|
|
|
|
var rpcResult = await _mqttClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
|
|
"fundamentals_Get",
|
|
requestDto,
|
|
TimeSpan.FromSeconds(forceRefresh ? 15 : 8)
|
|
);
|
|
|
|
if (rpcResult != null)
|
|
{
|
|
return Ok(rpcResult);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[ApiGateway] RPC fundamentals_Get für ISIN '{Isin}' fehlgeschlagen.", cleanIsin);
|
|
}
|
|
|
|
return NotFound(new { message = $"Keine Fundamentaldaten für ISIN '{cleanIsin}' vorhanden." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest Technische Analyse per ISIN.
|
|
/// </summary>
|
|
[HttpGet("{isin}/technicals")]
|
|
public async Task<IActionResult> GetTechnicals(
|
|
[FromRoute] string isin,
|
|
[FromQuery] bool forceRefresh = false,
|
|
[FromQuery] string? ticker = null)
|
|
{
|
|
string normalizedSymbol = isin.Trim().ToUpperInvariant();
|
|
if (string.IsNullOrWhiteSpace(normalizedSymbol))
|
|
{
|
|
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var rpcResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
|
"ta_GetAnalysis",
|
|
new IsinRequest(normalizedSymbol, ticker, forceRefresh),
|
|
TimeSpan.FromSeconds(5)
|
|
);
|
|
|
|
if (rpcResult != null)
|
|
{
|
|
return Ok(rpcResult);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "RPC ta_GetAnalysis für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
|
|
}
|
|
|
|
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
|
|
/// </summary>
|
|
[HttpGet("{isin}/derivatives")]
|
|
public async Task<IActionResult> GetDerivatives(
|
|
[FromRoute] string isin,
|
|
[FromQuery] string optionType = "long",
|
|
[FromQuery] decimal? targetLeverage = null,
|
|
[FromQuery] decimal? minLeverage = null,
|
|
[FromQuery] decimal? maxLeverage = null,
|
|
[FromQuery] string? search = null,
|
|
[FromQuery] string? after = null,
|
|
[FromQuery] int? page = null,
|
|
[FromQuery] int? pageSize = null,
|
|
[FromQuery] bool forceRefresh = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
string cleanIsin = isin.Trim().ToUpperInvariant();
|
|
if (string.IsNullOrWhiteSpace(cleanIsin))
|
|
{
|
|
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var req = new GetDerivativesRequest(
|
|
UnderlyingIsin: cleanIsin,
|
|
OptionType: optionType.ToLowerInvariant(),
|
|
TargetLeverage: targetLeverage,
|
|
After: after,
|
|
Page: page,
|
|
ForceRefresh: forceRefresh
|
|
);
|
|
|
|
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDerivativesRequest>(
|
|
"assets_GetDerivatives",
|
|
req,
|
|
TimeSpan.FromSeconds(30)
|
|
);
|
|
|
|
if (rpcResult != null)
|
|
{
|
|
var derivatives = rpcResult.OfType<DerivativeDto>().ToList();
|
|
|
|
if (minLeverage.HasValue)
|
|
{
|
|
derivatives = derivatives.Where(d => d.Leverage >= minLeverage.Value).ToList();
|
|
}
|
|
|
|
if (maxLeverage.HasValue)
|
|
{
|
|
derivatives = derivatives.Where(d => d.Leverage <= maxLeverage.Value).ToList();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
string q = search.Trim();
|
|
derivatives = derivatives.Where(d =>
|
|
(d.Isin != null && d.Isin.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
|
|
(d.Issuer != null && d.Issuer.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
|
|
(d.IssuerDisplayName != null && d.IssuerDisplayName.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
|
|
(d.ProductCategoryName != null && d.ProductCategoryName.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
).ToList();
|
|
}
|
|
|
|
derivatives = derivatives.OrderBy(d => d.Leverage).ToList();
|
|
|
|
int totalCount = derivatives.Count;
|
|
if (page.HasValue && pageSize.HasValue && page.Value > 0 && pageSize.Value > 0)
|
|
{
|
|
int pSize = Math.Clamp(pageSize.Value, 1, 200);
|
|
int pIndex = Math.Max(1, page.Value);
|
|
int totalPages = (int)Math.Ceiling((double)totalCount / pSize);
|
|
|
|
Response.Headers["X-Total-Count"] = totalCount.ToString();
|
|
Response.Headers["X-Total-Pages"] = totalPages.ToString();
|
|
Response.Headers["X-Current-Page"] = pIndex.ToString();
|
|
|
|
derivatives = derivatives.Skip((pIndex - 1) * pSize).Take(pSize).ToList();
|
|
}
|
|
|
|
return Ok(derivatives);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[AssetsController] Fehler beim Abrufen der Derivate für {Isin}", cleanIsin);
|
|
}
|
|
|
|
return Ok(new List<DerivativeDto>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
|
|
/// </summary>
|
|
[HttpGet("/api/v1/logo/{isin}")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> GetAssetLogo([FromRoute] string isin)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(isin)) return NotFound();
|
|
|
|
// Path Traversal Guard
|
|
string logoPath = Path.Combine(Volumes.LogosRelativePath, $"{isin}.svg");
|
|
|
|
if (System.IO.File.Exists(logoPath))
|
|
{
|
|
try
|
|
{
|
|
byte[] localData = await System.IO.File.ReadAllBytesAsync(logoPath);
|
|
return File(localData, "image/svg+xml");
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
// Fallback: SVG Placeholder
|
|
return File(PlaceholderSvgBytes, "image/svg+xml");
|
|
}
|
|
} |