222 lines
9.7 KiB
C#
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()
|
|
);
|
|
}
|
|
}
|
|
|