Files

430 lines
16 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.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 den aktuellen Live-Kurs eines Assets oder Derivats via TR MQTT RPC.
/// </summary>
[HttpGet("{isin}/live")]
public async Task<IActionResult> GetLivePrice([FromRoute] string isin)
{
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<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(normalizedSymbol),
TimeSpan.FromSeconds(5)
);
if (rpcResult != null && rpcResult.CurrentPrice > 0m)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RPC tr_GetLivePrice für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
}
return NotFound(new { message = $"Kein Live-Kurs 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<DerivativeDto>, GetDerivativesRequest>(
"assets_GetDerivatives",
req,
TimeSpan.FromSeconds(30)
);
if (rpcResult != null)
{
var derivatives = rpcResult;
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).
/// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7), not an
/// oversight: logos are static, non-user-specific assets (looked up only by ISIN), and generic image
/// loaders/`&lt;img&gt;` tags do not attach an <c>Authorization</c> header by default. Keeping this endpoint
/// anonymous lets every image loader render it without special-casing headers.
/// </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");
}
}