Files
Encelado/DesktopBot/Services/AlpacaTradingService.cs
T
2026-06-09 18:29:41 +02:00

542 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Alpaca.Markets;
using DesktopBot.Models;
using Newtonsoft.Json.Linq;
namespace DesktopBot.Services
{
/// <summary>
/// Implementazione del servizio di trading per Alpaca Markets API v2.
///
/// Conformità documentazione Alpaca:
/// - Autenticazione: SecretKey (header APCA-API-KEY-ID / APCA-API-SECRET-KEY)
/// - Paper Trading: Environments.Paper → paper-api.alpaca.markets
/// - Live Trading: Environments.Live → api.alpaca.markets
/// - Market Data: data.alpaca.markets (sempre, sia paper che live)
/// - Feed dati: "iex" (piano Basic gratuito; "sip" richiede Algo Trader Plus)
/// - Rate limit: 200 req/min Market Data, ~100 req/min Trading (Basic plan)
/// </summary>
/// <summary>
/// Implementazione locale di IBar per dati crypto deserializzati dall'API REST.
/// </summary>
internal sealed class SimpleBar : IBar
{
public string Symbol { get; set; }
public DateTime TimeUtc { get; set; }
public decimal Open { get; set; }
public decimal High { get; set; }
public decimal Low { get; set; }
public decimal Close { get; set; }
public decimal Volume { get; set; }
public decimal Vwap { get; set; }
public ulong TradeCount { get; set; }
}
public class AlpacaTradingService : ITradingService
{
private IAlpacaTradingClient _tradingClient;
private IAlpacaDataClient _dataClient;
private IAlpacaCryptoDataClient _cryptoDataClient;
private string _apiKey;
private string _apiSecret;
private bool _isInitialized;
private static readonly HttpClient _httpClient = new HttpClient();
private const string CryptoDataBaseUrl = "https://data.alpaca.markets";
/// <summary>Contatore e rate-limiter per le chiamate API Alpaca.</summary>
public ApiCallCounterService ApiCounter { get; } = new ApiCallCounterService();
/// <summary>
/// Inizializza i client per il trading e i dati di mercato.
/// Autenticazione tramite API Key/Secret (header APCA-API-KEY-ID / APCA-API-SECRET-KEY).
/// </summary>
public void Initialize(string apiKey, string apiSecret, bool isPaper)
{
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(apiSecret))
throw new ArgumentException("API Key e Secret sono obbligatori");
// Paper: paper-api.alpaca.markets (Trading) + data.alpaca.markets (Market Data)
// Live: api.alpaca.markets (Trading) + data.alpaca.markets (Market Data)
var environments = isPaper ? Environments.Paper : Environments.Live;
var secretKey = new SecretKey(apiKey, apiSecret);
_apiKey = apiKey;
_apiSecret = apiSecret;
_tradingClient = environments.GetAlpacaTradingClient(secretKey);
_dataClient = environments.GetAlpacaDataClient(secretKey);
_cryptoDataClient = environments.GetAlpacaCryptoDataClient(secretKey);
_isInitialized = true;
}
/// <summary>
/// Recupera l'equity disponibile del conto.
/// </summary>
public async Task<decimal> GetAvailableEquityAsync()
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAvailableEquity").ConfigureAwait(false);
IAccount account = await _tradingClient.GetAccountAsync();
return account.Equity ?? 0m;
}
/// <summary>
/// Recupera le informazioni complete del conto.
/// </summary>
public async Task<IAccount> GetAccountAsync()
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAccount").ConfigureAwait(false);
return await _tradingClient.GetAccountAsync();
}
/// <summary>
/// Recupera le barre storiche per un simbolo.
/// Per simboli crypto (contengono '/') usa l'endpoint REST v1beta3/crypto/us/bars
/// con paginazione automatica. Per simboli equity usa il feed IEX.
/// </summary>
public async Task<List<IBar>> GetHistoricalBarsAsync(string symbol, BarTimeFrame timeframe, int barCount)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.MarketData, "GetHistoricalBars").ConfigureAwait(false);
bool isCrypto = symbol.Contains("/") ||
symbol.Equals("BTCUSD", StringComparison.OrdinalIgnoreCase) ||
symbol.Equals("BTC/USD", StringComparison.OrdinalIgnoreCase);
if (isCrypto)
return await GetCryptoHistoricalBarsAsync(symbol, timeframe, barCount).ConfigureAwait(false);
// --- Equity: usa IAlpacaDataClient + feed IEX ---
DateTime endTime = DateTime.UtcNow;
DateTime startTime = CalculateStartTime(endTime, timeframe, (int)(barCount * 1.5));
var interval = new Interval<DateTime>(startTime, endTime);
var request = new HistoricalBarsRequest(symbol, timeframe, interval)
{
Feed = MarketDataFeed.Iex
};
IPage<IBar> page = await _dataClient.ListHistoricalBarsAsync(request).ConfigureAwait(false);
return page.Items.ToList();
}
/// <summary>
/// Recupera barre storiche crypto tramite chiamata diretta REST
/// GET /v1beta3/crypto/us/bars con paginazione automatica.
/// </summary>
private async Task<List<IBar>> GetCryptoHistoricalBarsAsync(
string symbol, BarTimeFrame timeframe, int barCount)
{
string apiSymbol = NormalizeCryptoSymbol(symbol);
// Margine del 50% per compensare ore notturne/festive senza candele
DateTime end = DateTime.UtcNow;
DateTime start = CalculateStartTime(end, timeframe, (int)(barCount * 1.5));
string tf = BarTimeFrameToString(timeframe);
string startStr = start.ToString("yyyy-MM-ddTHH:mm:ssZ");
string endStr = end.ToString("yyyy-MM-ddTHH:mm:ssZ");
var allBars = new List<IBar>();
string nextToken = null;
do
{
string url = string.Format(
"{0}/v1beta3/crypto/us/bars?symbols={1}&timeframe={2}&start={3}&end={4}&limit=1000&sort=asc",
CryptoDataBaseUrl,
Uri.EscapeDataString(apiSymbol),
tf,
Uri.EscapeDataString(startStr),
Uri.EscapeDataString(endStr));
if (nextToken != null)
url += "&page_token=" + Uri.EscapeDataString(nextToken);
using (var reqMsg = new HttpRequestMessage(HttpMethod.Get, url))
{
reqMsg.Headers.Add("APCA-API-KEY-ID", _apiKey);
reqMsg.Headers.Add("APCA-API-SECRET-KEY", _apiSecret);
reqMsg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
HttpResponseMessage resp = await _httpClient.SendAsync(reqMsg).ConfigureAwait(false);
string json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
string.Format("Alpaca crypto bars API error {0}: {1}", (int)resp.StatusCode, json));
JObject root = JObject.Parse(json);
nextToken = root["next_page_token"]?.Value<string>();
JToken barsToken = root["bars"]?[apiSymbol];
if (barsToken != null)
{
foreach (JToken b in barsToken)
{
allBars.Add(new SimpleBar
{
Symbol = apiSymbol,
TimeUtc = b["t"].Value<DateTime>().ToUniversalTime(),
Open = b["o"].Value<decimal>(),
High = b["h"].Value<decimal>(),
Low = b["l"].Value<decimal>(),
Close = b["c"].Value<decimal>(),
Volume = b["v"].Value<decimal>(),
Vwap = b["vw"] != null ? b["vw"].Value<decimal>() : 0m,
TradeCount = b["n"] != null ? (ulong)b["n"].Value<long>() : 0UL
});
}
}
}
}
while (nextToken != null && allBars.Count < barCount * 2);
// Restituisce le ultime barCount barre (le più recenti)
if (allBars.Count > barCount)
return allBars.Skip(allBars.Count - barCount).ToList();
return allBars;
}
private static string NormalizeCryptoSymbol(string symbol)
{
if (symbol.Equals("BTCUSD", StringComparison.OrdinalIgnoreCase)) return "BTC/USD";
if (symbol.Equals("ETHUSD", StringComparison.OrdinalIgnoreCase)) return "ETH/USD";
return symbol;
}
private static string BarTimeFrameToString(BarTimeFrame timeframe)
{
if (timeframe == BarTimeFrame.Minute) return "1Min";
if (timeframe == BarTimeFrame.Hour) return "1Hour";
if (timeframe == BarTimeFrame.Day) return "1Day";
return timeframe.ToString();
}
private static DateTime CalculateStartTime(DateTime end, BarTimeFrame timeframe, int count)
{
if (timeframe == BarTimeFrame.Minute) return end.AddMinutes(-count);
if (timeframe == BarTimeFrame.Hour) return end.AddHours(-count);
return end.AddDays(-count);
}
/// <summary>
/// Piazza un ordine bracket con Take Profit e Stop Loss.
/// Conforme alla documentazione Alpaca: MarketOrder + TakeProfit + StopLoss.
/// </summary>
public async Task<IOrder> PlaceBracketOrderAsync(string symbol, decimal quantity, decimal entryPrice,
decimal takeProfitPrice, decimal stopLossPrice, OrderSide side)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "PlaceBracketOrder").ConfigureAwait(false);
// Usa OrderQuantity.Fractional per crypto (es. 0.001 BTC), intero per equity
var orderQty = quantity == Math.Floor(quantity)
? OrderQuantity.FromInt64((long)quantity)
: OrderQuantity.Fractional(quantity);
var entryOrder = side == OrderSide.Buy
? MarketOrder.Buy(symbol, orderQty)
: MarketOrder.Sell(symbol, orderQty);
var bracketOrder = entryOrder
.TakeProfit(takeProfitPrice)
.StopLoss(stopLossPrice);
return await _tradingClient.PostOrderAsync(bracketOrder);
}
public async Task<IOrder> PlaceMarketOrderAsync(string symbol, decimal quantity, OrderSide side)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "PlaceMarketOrder").ConfigureAwait(false);
var orderQty = quantity == Math.Floor(quantity)
? OrderQuantity.FromInt64((long)quantity)
: OrderQuantity.Fractional(quantity);
var order = side == OrderSide.Buy
? MarketOrder.Buy(symbol, orderQty)
: MarketOrder.Sell(symbol, orderQty);
order.Duration = TimeInForce.Gtc;
order.ClientOrderId = "BOT_" + Guid.NewGuid().ToString("N").Substring(0, 16);
return await _tradingClient.PostOrderAsync(order);
}
public async Task<IOrder> PlaceStopOrderAsync(string symbol, decimal quantity, decimal stopPrice, OrderSide side)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "PlaceStopOrder").ConfigureAwait(false);
var orderQty = quantity == Math.Floor(quantity)
? OrderQuantity.FromInt64((long)quantity)
: OrderQuantity.Fractional(quantity);
var order = side == OrderSide.Buy
? StopOrder.Buy(symbol, orderQty, stopPrice)
: StopOrder.Sell(symbol, orderQty, stopPrice);
order.Duration = TimeInForce.Gtc;
order.ClientOrderId = "BOT_" + Guid.NewGuid().ToString("N").Substring(0, 16);
return await _tradingClient.PostOrderAsync(order);
}
public async Task<IOrder> PlaceLimitOrderAsync(string symbol, decimal quantity, decimal limitPrice, OrderSide side)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "PlaceLimitOrder").ConfigureAwait(false);
var orderQty = quantity == Math.Floor(quantity)
? OrderQuantity.FromInt64((long)quantity)
: OrderQuantity.Fractional(quantity);
var order = side == OrderSide.Buy
? LimitOrder.Buy(symbol, orderQty, limitPrice)
: LimitOrder.Sell(symbol, orderQty, limitPrice);
order.Duration = TimeInForce.Gtc;
order.ClientOrderId = "BOT_" + Guid.NewGuid().ToString("N").Substring(0, 16);
return await _tradingClient.PostOrderAsync(order);
}
/// <summary>
/// Verifica se esiste una posizione aperta per un simbolo.
/// Il codice 40410000 indica assenza di posizione (non è un errore reale).
/// </summary>
public async Task<bool> HasOpenPositionAsync(string symbol)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "HasOpenPosition").ConfigureAwait(false);
try
{
IPosition position = await _tradingClient.GetPositionAsync(symbol);
return position != null && Math.Abs(position.Quantity) > 0;
}
catch (RestClientErrorException ex) when (ex.ErrorCode == 40410000)
{
return false;
}
}
/// <summary>
/// Recupera la posizione aperta per un simbolo specifico.
/// Ritorna null se non esiste (codice 40410000 = no position).
/// </summary>
public async Task<IPosition> GetPositionAsync(string symbol)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetPosition").ConfigureAwait(false);
try
{
return await _tradingClient.GetPositionAsync(symbol);
}
catch (RestClientErrorException ex) when (ex.ErrorCode == 40410000)
{
return null;
}
}
/// <summary>
/// Recupera tutte le posizioni aperte.
/// </summary>
public async Task<IReadOnlyList<IPosition>> GetAllPositionsAsync()
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAllPositions").ConfigureAwait(false);
return await _tradingClient.ListPositionsAsync();
}
/// <summary>
/// Recupera l'ultimo prezzo disponibile per un simbolo.
/// Per crypto usa IAlpacaCryptoDataClient.GetLatestBarAsync (endpoint /v1beta3/crypto/us/latest/bars).
/// Per equity usa IAlpacaDataClient.GetLatestTradeAsync con feed IEX.
/// </summary>
public async Task<decimal> GetLatestPriceAsync(string symbol)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.MarketData, "GetLatestPrice").ConfigureAwait(false);
bool isCrypto = symbol.Contains("/") ||
symbol.Equals("BTCUSD", StringComparison.OrdinalIgnoreCase) ||
symbol.Equals("BTC/USD", StringComparison.OrdinalIgnoreCase);
if (isCrypto)
{
string apiSymbol = NormalizeCryptoSymbol(symbol);
// ListLatestBarsAsync restituisce un dizionario symbol -> IBar
var listRequest = new LatestDataListRequest(new[] { apiSymbol });
var barsDict = await _cryptoDataClient.ListLatestBarsAsync(listRequest).ConfigureAwait(false);
if (barsDict != null && barsDict.ContainsKey(apiSymbol))
return barsDict[apiSymbol].Close;
throw new InvalidOperationException(
string.Format("Nessun dato latest disponibile per {0}", apiSymbol));
}
// Equity: feed IEX gratuito
var request = new LatestMarketDataRequest(symbol)
{
Feed = MarketDataFeed.Iex
};
var latestTrade = await _dataClient.GetLatestTradeAsync(request).ConfigureAwait(false);
return latestTrade.Price;
}
/// <summary>
/// Recupera solo le posizioni aperte originate da ordini piazzati dal bot.
/// Strategia: lista gli ordini recenti (filled, lato Buy) con ClientOrderId
/// che inizia per "BOT_", ne estrae i simboli univoci e incrocia con le
/// posizioni effettivamente aperte su Alpaca.
/// </summary>
public async Task<IReadOnlyList<IPosition>> GetBotPositionsAsync()
{
EnsureInitialized();
// 1. Posizioni live
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetBotPositions_Pos").ConfigureAwait(false);
var allPositions = await _tradingClient.ListPositionsAsync().ConfigureAwait(false);
if (allPositions == null || allPositions.Count == 0)
return new List<IPosition>();
// 2. Ordini recenti (ultimi 500 filled) per trovare quelli del bot
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetBotPositions_Ord").ConfigureAwait(false);
var ordersRequest = new ListOrdersRequest
{
OrderStatusFilter = OrderStatusFilter.Closed,
LimitOrderNumber = 500
};
IReadOnlyList<IOrder> recentOrders;
try
{
recentOrders = await _tradingClient.ListOrdersAsync(ordersRequest).ConfigureAwait(false);
}
catch
{
// In caso di errore fallback: restituisce tutte le posizioni
return allPositions;
}
// 3. Simboli con almeno un ordine Buy filled piazzato dal bot
var botSymbols = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var o in recentOrders)
{
if (o.OrderSide == OrderSide.Buy &&
o.OrderStatus == OrderStatus.Filled &&
o.ClientOrderId != null &&
o.ClientOrderId.StartsWith("BOT_", StringComparison.OrdinalIgnoreCase))
{
botSymbols.Add(o.Symbol);
}
}
if (botSymbols.Count == 0)
{
// Nessun ordine bot trovato: fallback su tutte le posizioni
// (compatibilità con posizioni aperte prima dell'introduzione del prefisso)
return allPositions;
}
// 4. Filtra le posizioni per i simboli del bot
var botPositions = new List<IPosition>();
foreach (var p in allPositions)
{
if (botSymbols.Contains(p.Symbol))
botPositions.Add(p);
}
return botPositions;
}
/// <summary>
/// Chiude tutte le posizioni aperte.
/// </summary>
public async Task CloseAllPositionsAsync()
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "CloseAllPositions").ConfigureAwait(false);
await _tradingClient.DeleteAllPositionsAsync();
}
public async Task<IReadOnlyList<IOrder>> GetOrdersAsync(OrderStatusFilter statusFilter = OrderStatusFilter.Open, int limit = 50)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetOrders").ConfigureAwait(false);
var request = new ListOrdersRequest
{
OrderStatusFilter = statusFilter,
LimitOrderNumber = limit
};
return await _tradingClient.ListOrdersAsync(request);
}
public async Task CancelOrderAsync(Guid orderId)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "CancelOrder").ConfigureAwait(false);
await _tradingClient.CancelOrderAsync(orderId);
}
/// <summary>
/// Chiude una singola posizione per simbolo (DELETE /v2/positions/{symbol}).
/// Ignora silenziosamente se la posizione non esiste già (già chiusa).
/// </summary>
public async Task ClosePositionAsync(string symbol)
{
EnsureInitialized();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "ClosePosition").ConfigureAwait(false);
try
{
await _tradingClient.DeletePositionAsync(new DeletePositionRequest(symbol));
}
catch (Alpaca.Markets.RestClientErrorException ex) when (ex.Message.Contains("not found"))
{
// Posizione già chiusa o non esiste; ok
}
}
/// <summary>
/// Cerca asset tradabili per simbolo o nome (case-insensitive, starts-with / contains).
/// Usa ListAssetsAsync con AssetStatus.Active + AssetClass.UsEquity o Crypto.
/// La ricerca viene effettuata localmente sul risultato della lista asset.
/// </summary>
public async Task<List<AssetSearchResult>> SearchAssetsAsync(string query, int maxResults = 20)
{
EnsureInitialized();
if (string.IsNullOrWhiteSpace(query))
return new List<AssetSearchResult>();
await ApiCounter.ThrottleAsync(ApiCategory.Trading, "SearchAssets").ConfigureAwait(false);
var request = new AssetsRequest { AssetStatus = AssetStatus.Active };
var assets = await _tradingClient.ListAssetsAsync(request).ConfigureAwait(false);
var q = query.Trim().ToUpperInvariant();
return assets
.Where(a => a.IsTradable &&
(a.Symbol.StartsWith(q, StringComparison.OrdinalIgnoreCase) ||
(a.Name != null && a.Name.IndexOf(q, StringComparison.OrdinalIgnoreCase) >= 0)))
.OrderBy(a => a.Symbol.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(a => a.Symbol)
.Take(maxResults)
.Select(a => new AssetSearchResult
{
Symbol = a.Symbol,
Name = a.Name ?? a.Symbol,
AssetClass = a.Class.ToString(),
Exchange = a.Exchange.ToString(),
Tradable = a.IsTradable
})
.ToList();
}
/// <summary>
/// Verifica che il servizio sia stato inizializzato
/// </summary>
private void EnsureInitialized()
{
if (!_isInitialized)
{
throw new InvalidOperationException("Il servizio non è stato inizializzato. Chiamare Initialize() prima di usarlo.");
}
}
}
}