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 { /// /// 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) /// /// /// Implementazione locale di IBar per dati crypto deserializzati dall'API REST. /// 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"; /// Contatore e rate-limiter per le chiamate API Alpaca. public ApiCallCounterService ApiCounter { get; } = new ApiCallCounterService(); /// /// 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). /// 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; } /// /// Recupera l'equity disponibile del conto. /// public async Task GetAvailableEquityAsync() { EnsureInitialized(); await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAvailableEquity").ConfigureAwait(false); IAccount account = await _tradingClient.GetAccountAsync(); return account.Equity ?? 0m; } /// /// Recupera le informazioni complete del conto. /// public async Task GetAccountAsync() { EnsureInitialized(); await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAccount").ConfigureAwait(false); return await _tradingClient.GetAccountAsync(); } /// /// 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. /// public async Task> 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(startTime, endTime); var request = new HistoricalBarsRequest(symbol, timeframe, interval) { Feed = MarketDataFeed.Iex }; IPage page = await _dataClient.ListHistoricalBarsAsync(request).ConfigureAwait(false); return page.Items.ToList(); } /// /// Recupera barre storiche crypto tramite chiamata diretta REST /// GET /v1beta3/crypto/us/bars con paginazione automatica. /// private async Task> 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(); 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(); JToken barsToken = root["bars"]?[apiSymbol]; if (barsToken != null) { foreach (JToken b in barsToken) { allBars.Add(new SimpleBar { Symbol = apiSymbol, TimeUtc = b["t"].Value().ToUniversalTime(), Open = b["o"].Value(), High = b["h"].Value(), Low = b["l"].Value(), Close = b["c"].Value(), Volume = b["v"].Value(), Vwap = b["vw"] != null ? b["vw"].Value() : 0m, TradeCount = b["n"] != null ? (ulong)b["n"].Value() : 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); } /// /// Piazza un ordine bracket con Take Profit e Stop Loss. /// Conforme alla documentazione Alpaca: MarketOrder + TakeProfit + StopLoss. /// public async Task 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 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 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 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); } /// /// Verifica se esiste una posizione aperta per un simbolo. /// Il codice 40410000 indica assenza di posizione (non è un errore reale). /// public async Task 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; } } /// /// Recupera la posizione aperta per un simbolo specifico. /// Ritorna null se non esiste (codice 40410000 = no position). /// public async Task 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; } } /// /// Recupera tutte le posizioni aperte. /// public async Task> GetAllPositionsAsync() { EnsureInitialized(); await ApiCounter.ThrottleAsync(ApiCategory.Trading, "GetAllPositions").ConfigureAwait(false); return await _tradingClient.ListPositionsAsync(); } /// /// 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. /// public async Task 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; } /// /// 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. /// public async Task> 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(); // 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 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(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(); foreach (var p in allPositions) { if (botSymbols.Contains(p.Symbol)) botPositions.Add(p); } return botPositions; } /// /// Chiude tutte le posizioni aperte. /// public async Task CloseAllPositionsAsync() { EnsureInitialized(); await ApiCounter.ThrottleAsync(ApiCategory.Trading, "CloseAllPositions").ConfigureAwait(false); await _tradingClient.DeleteAllPositionsAsync(); } public async Task> 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); } /// /// Chiude una singola posizione per simbolo (DELETE /v2/positions/{symbol}). /// Ignora silenziosamente se la posizione non esiste già (già chiusa). /// 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 } } /// /// 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. /// public async Task> SearchAssetsAsync(string query, int maxResults = 20) { EnsureInitialized(); if (string.IsNullOrWhiteSpace(query)) return new List(); 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(); } /// /// Verifica che il servizio sia stato inizializzato /// private void EnsureInitialized() { if (!_isInitialized) { throw new InvalidOperationException("Il servizio non è stato inizializzato. Chiamare Initialize() prima di usarlo."); } } } }