refactor(bot): update paper trading models, broker integration, background services, and test project
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
|
||||
namespace FinlyticBot.Services.Alpaca;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a confirmed Alpaca position liquidation (<see cref="IAlpacaTradingService.ClosePositionAsync"/>).
|
||||
/// Only ever constructed after Alpaca's REST API has accepted the liquidation order — see the method's
|
||||
/// XML doc for why callers may treat its mere existence as proof the broker confirmed the close.
|
||||
/// </summary>
|
||||
/// <param name="OrderId">The Alpaca order ID of the liquidation (market) order.</param>
|
||||
/// <param name="OrderStatus">The Alpaca order status returned immediately after submission (e.g. "Accepted", "Filled").</param>
|
||||
/// <param name="AverageFillPrice">
|
||||
/// The average fill price if Alpaca already reports one at submission time; <see langword="null"/> when the
|
||||
/// liquidation order has been accepted but not yet filled (e.g. outside market hours). Callers must fall back
|
||||
/// to the position's last known synced price in that case rather than treating <see langword="null"/> as zero.
|
||||
/// </param>
|
||||
public record AlpacaPositionCloseResult(string OrderId, string OrderStatus, decimal? AverageFillPrice);
|
||||
|
||||
public interface IAlpacaTradingService
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task<string> PlaceBracketOrderAsync(
|
||||
string symbol,
|
||||
SignalDirection direction,
|
||||
decimal quantity,
|
||||
decimal entryPrice,
|
||||
decimal stopLossPrice,
|
||||
decimal takeProfitPrice,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpdateStopLossAsync(
|
||||
string alpacaOrderId,
|
||||
decimal newStopLossPrice,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task CancelOrderAsync(
|
||||
string alpacaOrderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Liquidates the entire open position for <paramref name="symbol"/> at market price via Alpaca's native
|
||||
/// position-close endpoint. Returns only once Alpaca has ACCEPTED the liquidation order — a caller (e.g.
|
||||
/// the panic-close handler) must only mark the corresponding local position as closed AFTER this call
|
||||
/// returns without throwing, never optimistically before calling it.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable.</exception>
|
||||
Task<AlpacaPositionCloseResult> ClosePositionAsync(
|
||||
string symbol,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AccountSummaryDto> GetPortfolioSummaryAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user