Files
Finlytic/FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs
T

222 lines
9.7 KiB
C#

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<AlpacaPaperTradingService> _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<AlpacaPaperTradingService> logger)
{
_settingsService = settingsService;
_configuration = configuration;
_logger = logger;
}
private async Task<IAlpacaTradingClient?> 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<string> 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);
}
}
/// <summary>
/// Liquidates the entire open position for <paramref name="symbol"/> at market price using Alpaca's
/// native <c>DELETE /v2/positions/{symbol}</c> endpoint (<see cref="IAlpacaTradingClient.DeletePositionAsync"/>).
/// Unlike <see cref="PlaceBracketOrderAsync"/> (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
/// <c>FinlyticBot.Util.BotMqttClient</c>'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.
/// </summary>
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable — no liquidation was attempted.</exception>
public async Task<AlpacaPositionCloseResult> 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);
}
/// <summary>
/// Ruft die echte Alpaca-Kontoübersicht ab. Wirft eine <see cref="InvalidOperationException"/>,
/// 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 <see cref="FinlyticCore.Dtos.Bot.BotExecutionVenue.SyntheticPaperBroker"/>
/// als Venue wählen und den dedizierten Ledger (<c>ISyntheticPaperBroker</c>) verwenden.
/// </summary>
public async Task<AccountSummaryDto> 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()
);
}
}