using System; using System.Threading; using System.Threading.Tasks; using Alpaca.Markets; using FinlyticCore.Dtos.Bot; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Services; using FinlyticBot.Settings; using Microsoft.Extensions.Configuration; namespace FinlyticBot.Services.Alpaca; public class AlpacaPaperTradingService : IAlpacaTradingService { private readonly ISettingsService _settingsService; private readonly IConfiguration _configuration; private readonly IFinlyticLogger _logger; private string _cachedKeyId = ""; private string _cachedSecretKey = ""; private bool _cachedIsPaper = true; private IAlpacaTradingClient? _tradingClient; private readonly SemaphoreSlim _clientLock = new(1, 1); public bool IsConfigured => _tradingClient != null || !string.IsNullOrWhiteSpace(_cachedKeyId); public AlpacaPaperTradingService( ISettingsService settingsService, IConfiguration configuration, IFinlyticLogger logger) { _settingsService = settingsService; _configuration = configuration; _logger = logger; } private async Task GetTradingClientAsync(CancellationToken cancellationToken = default) { var keyId = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaKeyId, cancellationToken); if (string.IsNullOrWhiteSpace(keyId)) { keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? ""; } var secretKey = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaSecretKey, cancellationToken); if (string.IsNullOrWhiteSpace(secretKey)) { secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? ""; } var isPaper = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaIsPaper, cancellationToken); keyId = keyId.Trim(); secretKey = secretKey.Trim(); if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey) || keyId.Contains("PLACEHOLDER", StringComparison.OrdinalIgnoreCase)) { return null; } if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper) { return _tradingClient; } await _clientLock.WaitAsync(cancellationToken); try { if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper) { return _tradingClient; } var secretKeyObj = new SecretKey(keyId, secretKey); var environment = isPaper ? global::Alpaca.Markets.Environments.Paper : global::Alpaca.Markets.Environments.Live; _tradingClient = environment.GetAlpacaTradingClient(secretKeyObj); _cachedKeyId = keyId; _cachedSecretKey = secretKey; _cachedIsPaper = isPaper; await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Initialized Alpaca Client (Paper: {IsPaper}) with Key {KeyIdPrefix}...", isPaper, keyId.Substring(0, Math.Min(4, keyId.Length))); return _tradingClient; } catch (Exception ex) { await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex, "[AlpacaService] Failed to initialize Alpaca client."); return null; } finally { _clientLock.Release(); } } public async Task PlaceBracketOrderAsync( string symbol, SignalDirection direction, decimal quantity, decimal entryPrice, decimal stopLossPrice, decimal takeProfitPrice, CancellationToken cancellationToken = default) { var client = await GetTradingClientAsync(cancellationToken); if (client == null) { throw new InvalidOperationException("Alpaca Paper Trading Client is not configured or offline."); } var orderSide = direction == SignalDirection.Buy ? OrderSide.Buy : OrderSide.Sell; var orderRequest = orderSide.Market(symbol, OrderQuantity.Fractional(quantity)) .Bracket(takeProfitPrice, stopLossPrice); var order = await client.PostOrderAsync(orderRequest, cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Placed Alpaca Bracket Order {OrderId} for {Symbol} (Side: {Side}, Qty: {Qty}, SL: {SL:F2}, TP: {TP:F2})", order.OrderId, symbol, orderSide, quantity, stopLossPrice, takeProfitPrice); return order.OrderId.ToString(); } public async Task UpdateStopLossAsync( string alpacaOrderId, decimal newStopLossPrice, CancellationToken cancellationToken = default) { var client = await GetTradingClientAsync(cancellationToken); if (client == null) return; if (!Guid.TryParse(alpacaOrderId, out var orderGuid)) { await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Invalid Alpaca Order ID format: {Id}", alpacaOrderId); return; } var replaceRequest = new ChangeOrderRequest(orderGuid) { StopPrice = newStopLossPrice }; await client.PatchOrderAsync(replaceRequest, cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Updated Stop-Loss for Alpaca Order {OrderId} to {NewSL:F2}", orderGuid, newStopLossPrice); } public async Task CancelOrderAsync(string alpacaOrderId, CancellationToken cancellationToken = default) { var client = await GetTradingClientAsync(cancellationToken); if (client == null) return; if (Guid.TryParse(alpacaOrderId, out var orderGuid)) { await client.CancelOrderAsync(orderGuid, cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Canceled Alpaca Order {OrderId}", orderGuid); } } /// /// Liquidates the entire open position for at market price using Alpaca's /// native DELETE /v2/positions/{symbol} endpoint (). /// Unlike (which only ever submits an order and says nothing about /// whether the position it opens is confirmed), a successful return from this method means Alpaca's REST /// API has ACCEPTED the liquidation request for the position — this is the only signal in this service /// that is safe to treat as authoritative proof of a close. Callers (see /// FinlyticBot.Util.BotMqttClient's panic-close handler) must persist the position as closed only /// after this call returns without throwing, never optimistically beforehand: a false "closed" marking on /// a still-open real position is the single worst outcome a panic-close feature could produce. /// /// Alpaca is not configured/reachable — no liquidation was attempted. public async Task ClosePositionAsync(string symbol, CancellationToken cancellationToken = default) { var client = await GetTradingClientAsync(cancellationToken); if (client == null) { throw new InvalidOperationException( $"Alpaca Paper Trading Client is not configured or offline. Cannot confirm liquidation of position '{symbol}'."); } var order = await client.DeletePositionAsync(new DeletePositionRequest(symbol), cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Alpaca accepted market liquidation for position {Symbol}: Order {OrderId} (Status: {Status}, AvgFill: {AvgFill}).", symbol, order.OrderId, order.OrderStatus, order.AverageFillPrice?.ToString("F2") ?? "n/a (not yet filled)"); return new AlpacaPositionCloseResult(order.OrderId.ToString(), order.OrderStatus.ToString(), order.AverageFillPrice); } /// /// Ruft die echte Alpaca-Kontoübersicht ab. Wirft eine , /// wenn der Alpaca-Client nicht konfiguriert/initialisierbar ist - es werden bewusst KEINE /// erfundenen Platzhalterzahlen zurückgegeben (Rules.md §4). Wer synthetisches Paper-Trading /// betreiben möchte, muss explizit /// als Venue wählen und den dedizierten Ledger (ISyntheticPaperBroker) verwenden. /// public async Task GetPortfolioSummaryAsync(CancellationToken cancellationToken = default) { var client = await GetTradingClientAsync(cancellationToken); if (client == null) { await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] GetPortfolioSummaryAsync failed: Alpaca client is not configured or offline. Refusing to return synthetic placeholder data."); throw new InvalidOperationException( "Alpaca Paper Trading Client is not configured or offline. Keine echte Kontoübersicht verfügbar."); } var account = await client.GetAccountAsync(cancellationToken); return new AccountSummaryDto( Equity: account.Equity ?? 0m, Cash: account.TradableCash, BuyingPower: account.BuyingPower ?? 0m, Currency: account.Currency ?? "USD", Status: account.Status.ToString() ); } }