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; /// /// DTO for asset search results (AOT-compliant). /// public record AssetSearchResultDto( [property: JsonPropertyName("symbol")] string Symbol, [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("isin")] string Isin, [property: JsonPropertyName("image")] string Image ); /// /// DTO for discovery assets (AOT-compliant). /// 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 Tags ); [ApiController] [Authorize] [Route("api/v1/assets")] public class AssetsController : ControllerBase { private readonly WebMqttClient _mqttClient; private readonly BackendDbContext _dbContext; private readonly ILogger _logger; private static List? _cachedIndexAssets = null; private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes(""" """); public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger logger) { _mqttClient = mqttClient; _dbContext = dbContext; _logger = logger; } /// /// Lädt den AssetIndex direkt aus der gemounteten index.json. /// public static List 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>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex); if (assets != null && assets.Count > 0) { _cachedIndexAssets = assets; return assets; } } catch { } } return []; } /// /// Durchsucht Assets im Index. /// [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); } /// /// Discovery-Endpunkt via RPC mit Fallback auf die index.json. /// [HttpGet("discovery")] public async Task GetDiscoveryAssets([FromQuery] int limit = 15) { try { if (_mqttClient.IsConnected) { var rpcResult = await _mqttClient.SendRpcRequestAsync, 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() )).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() )).ToList(); return Ok(fallback); } /// /// Liest Fundamentaldaten per ISIN. /// [HttpGet("{isin}/fundamentals")] public async Task 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( "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." }); } /// /// Liest Technische Analyse per ISIN. /// [HttpGet("{isin}/technicals")] public async Task 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( "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." }); } /// /// Liest den aktuellen Live-Kurs eines Assets oder Derivats via TR MQTT RPC. /// [HttpGet("{isin}/live")] public async Task 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( "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." }); } /// /// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC. /// [HttpGet("{isin}/derivatives")] public async Task 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, 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()); } /// /// 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/`<img>` tags do not attach an Authorization header by default. Keeping this endpoint /// anonymous lets every image loader render it without special-casing headers. /// [HttpGet("/api/v1/logo/{isin}")] [AllowAnonymous] public async Task 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"); } }