Sviluppo TradingBot

This commit is contained in:
2026-06-09 18:29:41 +02:00
parent 61f1e59964
commit e3c0bd51b2
133 changed files with 24903 additions and 1 deletions
+101
View File
@@ -0,0 +1,101 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopBot.Services
{
/// <summary>
/// Misura la latenza (ping) verso l'endpoint di trading Alpaca.
/// Usa una richiesta GET su /v2/clock che è leggera e non richiede
/// autenticazione nella versione pubblica.
/// </summary>
public class AlpacaPingService : IDisposable
{
// endpoint pubblico leggero di Alpaca (non richiede auth, risponde 200)
private const string PingUrlPaper = "https://paper-api.alpaca.markets/v2/clock";
private const string PingUrlLive = "https://api.alpaca.markets/v2/clock";
private readonly HttpClient _http;
private CancellationTokenSource _cts;
private bool _isPaper = true;
public event EventHandler<PingResult> PingCompleted;
public AlpacaPingService()
{
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
}
public void SetEnvironment(bool isPaper) => _isPaper = isPaper;
public void Start(int intervalSeconds = 10)
{
_cts?.Cancel();
_cts = new CancellationTokenSource();
_ = PingLoopAsync(intervalSeconds, _cts.Token);
}
public void Stop() => _cts?.Cancel();
private async Task PingLoopAsync(int intervalSeconds, CancellationToken ct)
{
// Prima misura immediata
await MeasureAsync();
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct); }
catch (OperationCanceledException) { return; }
await MeasureAsync();
}
}
private async Task MeasureAsync()
{
string url = _isPaper ? PingUrlPaper : PingUrlLive;
var sw = Stopwatch.StartNew();
try
{
using var req = new HttpRequestMessage(HttpMethod.Head, url);
using var resp = await _http.SendAsync(req).ConfigureAwait(false);
sw.Stop();
PingCompleted?.Invoke(this, new PingResult
{
LatencyMs = (int)sw.ElapsedMilliseconds,
IsSuccess = resp.IsSuccessStatusCode || (int)resp.StatusCode == 403 // 403 = raggiungibile ma senza auth
});
}
catch
{
sw.Stop();
PingCompleted?.Invoke(this, new PingResult
{
LatencyMs = -1,
IsSuccess = false
});
}
}
public void Dispose()
{
_cts?.Cancel();
_http?.Dispose();
}
}
public class PingResult
{
/// <summary>Latenza in millisecondi. -1 se non raggiungibile.</summary>
public int LatencyMs { get; set; }
public bool IsSuccess { get; set; }
public PingStatus Status =>
!IsSuccess ? PingStatus.Offline :
LatencyMs < 80 ? PingStatus.Good :
LatencyMs < 250 ? PingStatus.Fair :
PingStatus.Poor;
}
public enum PingStatus { Good, Fair, Poor, Offline }
}
+541
View File
@@ -0,0 +1,541 @@
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.");
}
}
}
}
@@ -0,0 +1,209 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopBot.Services
{
/// <summary>
/// Categoria di chiamata API verso Alpaca.
/// </summary>
public enum ApiCategory
{
/// <summary>Trading API: ordini, posizioni, conto (paper-api / api.alpaca.markets)</summary>
Trading,
/// <summary>Market Data API: barre storiche, prezzi real-time (data.alpaca.markets)</summary>
MarketData
}
/// <summary>
/// Monitora e limita la frequenza delle chiamate verso le API Alpaca.
///
/// Limiti piano Basic (gratuito) da documentazione ufficiale Alpaca:
/// - Market Data API (Historical): 200 req/min
/// - Trading API: nessun limite documentato pubblicamente,
/// usa 100 req/min come valore conservativo
///
/// Fonte: https://docs.alpaca.markets/docs/about-market-data-api#subscription-plans
/// </summary>
public class ApiCallCounterService : INotifyPropertyChanged
{
// ── Limiti per piano Basic ──────────────────────────────────────────────
public const int MarketDataRpmLimit = 200; // req/min Basic plan (gratuito)
public const int TradingRpmLimit = 100; // req/min valore conservativo
// ── Finestra sliding di 60 secondi ─────────────────────────────────────
private static readonly TimeSpan _window = TimeSpan.FromSeconds(60);
private readonly ConcurrentQueue<DateTime> _marketDataTimestamps = new ConcurrentQueue<DateTime>();
private readonly ConcurrentQueue<DateTime> _tradingTimestamps = new ConcurrentQueue<DateTime>();
// ── Totali cumulativi ───────────────────────────────────────────────────
private long _totalMarketData;
private long _totalTrading;
private long _throttledCalls;
private readonly object _uiLock = new object();
// ── Proprietà notificabili per la UI ───────────────────────────────────
private int _marketDataRpm;
/// <summary>Chiamate Market Data API negli ultimi 60 secondi.</summary>
public int MarketDataRpm
{
get => _marketDataRpm;
private set { if (_marketDataRpm != value) { _marketDataRpm = value; OnPropertyChanged(); OnPropertyChanged(nameof(MarketDataUsagePercent)); } }
}
private int _tradingRpm;
/// <summary>Chiamate Trading API negli ultimi 60 secondi.</summary>
public int TradingRpm
{
get => _tradingRpm;
private set { if (_tradingRpm != value) { _tradingRpm = value; OnPropertyChanged(); OnPropertyChanged(nameof(TradingUsagePercent)); } }
}
/// <summary>Totale cumulativo chiamate Market Data dalla creazione.</summary>
public long TotalMarketDataCalls => Interlocked.Read(ref _totalMarketData);
/// <summary>Totale cumulativo chiamate Trading dalla creazione.</summary>
public long TotalTradingCalls => Interlocked.Read(ref _totalTrading);
/// <summary>Totale chiamate rallentate per rate-limit.</summary>
public long ThrottledCalls => Interlocked.Read(ref _throttledCalls);
/// <summary>Percentuale utilizzo finestra 60s per Market Data (0100).</summary>
public double MarketDataUsagePercent => MarketDataRpm * 100.0 / MarketDataRpmLimit;
/// <summary>Percentuale utilizzo finestra 60s per Trading (0100).</summary>
public double TradingUsagePercent => TradingRpm * 100.0 / TradingRpmLimit;
/// <summary>True se Market Data è sopra il 90% del limite.</summary>
public bool IsMarketDataNearLimit => MarketDataRpm >= MarketDataRpmLimit * 0.9;
/// <summary>True se Trading è sopra il 90% del limite.</summary>
public bool IsTradingNearLimit => TradingRpm >= TradingRpmLimit * 0.9;
// ── Statistiche per categoria ───────────────────────────────────────────
private readonly ConcurrentDictionary<string, long> _callsByEndpoint =
new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
// ── Costruttore ─────────────────────────────────────────────────────────
public ApiCallCounterService()
{
// Timer per aggiornare la UI ogni secondo
var timer = new Timer(_ => RefreshRpmCounters(), null,
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
// ── API pubblica ────────────────────────────────────────────────────────
/// <summary>
/// Attende se necessario rispettando il rate limit, poi registra la chiamata.
/// Deve essere chiamato PRIMA di ogni richiesta HTTP verso Alpaca.
/// </summary>
/// <param name="category">Categoria della chiamata.</param>
/// <param name="endpoint">Nome endpoint per statistiche (es. "GetAccount").</param>
/// <param name="cancellationToken">Token di cancellazione.</param>
public async Task ThrottleAsync(ApiCategory category, string endpoint = "",
CancellationToken cancellationToken = default)
{
var queue = category == ApiCategory.MarketData
? _marketDataTimestamps : _tradingTimestamps;
var limit = category == ApiCategory.MarketData
? MarketDataRpmLimit : TradingRpmLimit;
// Attendi finché la finestra non è libera
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
Purge(queue);
if (queue.Count < limit)
break;
Interlocked.Increment(ref _throttledCalls);
// Attende il tempo residuo prima che il più vecchio timestamp esca dalla finestra
DateTime oldest;
if (queue.TryPeek(out oldest))
{
var wait = _window - (DateTime.UtcNow - oldest);
if (wait > TimeSpan.Zero)
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
}
}
// Registra la chiamata
queue.Enqueue(DateTime.UtcNow);
if (category == ApiCategory.MarketData)
Interlocked.Increment(ref _totalMarketData);
else
Interlocked.Increment(ref _totalTrading);
if (!string.IsNullOrEmpty(endpoint))
_callsByEndpoint.AddOrUpdate(endpoint, 1, (_, v) => v + 1);
RefreshRpmCounters();
}
/// <summary>
/// Restituisce le statistiche per endpoint (nome → conteggio totale).
/// </summary>
public IReadOnlyDictionary<string, long> GetEndpointStats()
=> new Dictionary<string, long>(_callsByEndpoint);
/// <summary>
/// Azzera i contatori cumulativi (non il rate-limiter sliding).
/// </summary>
public void Reset()
{
Interlocked.Exchange(ref _totalMarketData, 0);
Interlocked.Exchange(ref _totalTrading, 0);
Interlocked.Exchange(ref _throttledCalls, 0);
_callsByEndpoint.Clear();
RefreshRpmCounters();
OnPropertyChanged(nameof(TotalMarketDataCalls));
OnPropertyChanged(nameof(TotalTradingCalls));
OnPropertyChanged(nameof(ThrottledCalls));
}
// ── Privati ─────────────────────────────────────────────────────────────
private void Purge(ConcurrentQueue<DateTime> queue)
{
var cutoff = DateTime.UtcNow - _window;
DateTime ts;
while (queue.TryPeek(out ts) && ts < cutoff)
queue.TryDequeue(out _);
}
private void RefreshRpmCounters()
{
Purge(_marketDataTimestamps);
Purge(_tradingTimestamps);
lock (_uiLock)
{
MarketDataRpm = _marketDataTimestamps.Count;
TradingRpm = _tradingTimestamps.Count;
}
OnPropertyChanged(nameof(TotalMarketDataCalls));
OnPropertyChanged(nameof(TotalTradingCalls));
OnPropertyChanged(nameof(ThrottledCalls));
OnPropertyChanged(nameof(IsMarketDataNearLimit));
OnPropertyChanged(nameof(IsTradingNearLimit));
}
// ── INotifyPropertyChanged ──────────────────────────────────────────────
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace DesktopBot.Services
{
/// <summary>
/// Gestisce il salvataggio e il caricamento sicuro delle credenziali Alpaca.
/// Cifra con AES-256 usando una chiave derivata da MachineName + UserName.
/// </summary>
public static class CredentialService
{
private static readonly string SettingsFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TradingBot");
private static readonly string CredentialsFile =
Path.Combine(SettingsFolder, "credentials.dat");
private static byte[] DeriveKey()
{
var seed = Environment.MachineName + Environment.UserName + "TradingBotSalt_v1";
using (var sha = SHA256.Create())
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
public static void SaveCredentials(string apiKey, string apiSecret, bool isPaper)
{
if (!Directory.Exists(SettingsFolder))
Directory.CreateDirectory(SettingsFolder);
var plainText = apiKey + "|" + apiSecret + "|" + isPaper;
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var key = DeriveKey();
using (var aes = Aes.Create())
{
aes.Key = key;
aes.GenerateIV();
using (var ms = new MemoryStream())
{
ms.Write(aes.IV, 0, aes.IV.Length);
using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
cs.Write(plainBytes, 0, plainBytes.Length);
File.WriteAllBytes(CredentialsFile, ms.ToArray());
}
}
}
public static AlpacaCredentials LoadCredentials()
{
if (!File.Exists(CredentialsFile))
return null;
try
{
var data = File.ReadAllBytes(CredentialsFile);
var key = DeriveKey();
using (var aes = Aes.Create())
{
aes.Key = key;
var iv = new byte[16];
Array.Copy(data, 0, iv, 0, 16);
aes.IV = iv;
using (var ms = new MemoryStream(data, 16, data.Length - 16))
using (var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read))
using (var reader = new StreamReader(cs, Encoding.UTF8))
{
var plain = reader.ReadToEnd();
var parts = plain.Split('|');
if (parts.Length != 3) return null;
return new AlpacaCredentials
{
ApiKey = parts[0],
ApiSecret = parts[1],
IsPaper = bool.Parse(parts[2])
};
}
}
}
catch { return null; }
}
public static bool HasCredentials() => File.Exists(CredentialsFile);
public static void DeleteCredentials()
{
if (File.Exists(CredentialsFile))
File.Delete(CredentialsFile);
}
}
/// <summary>Modello credenziali Alpaca</summary>
public class AlpacaCredentials
{
public string ApiKey { get; set; }
public string ApiSecret { get; set; }
public bool IsPaper { get; set; }
}
}
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Alpaca.Markets;
using DesktopBot.Models;
namespace DesktopBot.Services
{
/// <summary>
/// Interfaccia per il servizio di trading - permette disaccoppiamento e testing
/// </summary>
public interface ITradingService
{
/// <summary>
/// Inizializza il client di trading con le credenziali API
/// </summary>
/// <param name="apiKey">Chiave API Alpaca</param>
/// <param name="apiSecret">Secret API Alpaca</param>
/// <param name="isPaper">True per Paper Trading, False per Live</param>
void Initialize(string apiKey, string apiSecret, bool isPaper);
/// <summary>
/// Recupera il saldo disponibile del conto
/// </summary>
Task<decimal> GetAvailableEquityAsync();
/// <summary>
/// Recupera le informazioni complete del conto (saldo, P&amp;L, buying power, ecc.)
/// </summary>
Task<IAccount> GetAccountAsync();
/// <summary>
/// Recupera le barre storiche (candele) per un simbolo
/// </summary>
/// <param name="symbol">Simbolo ticker (es. "AAPL")</param>
/// <param name="timeframe">Timeframe delle candele</param>
/// <param name="barCount">Numero di barre da recuperare (convertito in intervallo temporale appropriato)</param>
Task<List<IBar>> GetHistoricalBarsAsync(string symbol, BarTimeFrame timeframe, int barCount);
/// <summary>
/// Piazza un ordine bracket (con Take Profit e Stop Loss automatici).
/// NON supportato per crypto su Alpaca: usare PlaceMarketOrderAsync + SL/TP separati.
/// </summary>
Task<IOrder> PlaceBracketOrderAsync(string symbol, decimal quantity, decimal entryPrice,
decimal takeProfitPrice, decimal stopLossPrice, OrderSide side);
/// <summary>
/// Piazza un ordine market semplice (buy o sell). Usare per crypto.
/// </summary>
Task<IOrder> PlaceMarketOrderAsync(string symbol, decimal quantity, OrderSide side);
/// <summary>
/// Piazza un ordine stop-loss separato su una posizione aperta (per crypto).
/// </summary>
Task<IOrder> PlaceStopOrderAsync(string symbol, decimal quantity, decimal stopPrice, OrderSide side);
/// <summary>
/// Piazza un ordine limit (take-profit) separato su una posizione aperta (per crypto).
/// </summary>
Task<IOrder> PlaceLimitOrderAsync(string symbol, decimal quantity, decimal limitPrice, OrderSide side);
/// <summary>
/// Verifica se esiste una posizione aperta per un simbolo
/// </summary>
Task<bool> HasOpenPositionAsync(string symbol);
/// <summary>
/// Recupera tutte le posizioni aperte
/// </summary>
Task<IReadOnlyList<IPosition>> GetAllPositionsAsync();
/// <summary>
/// Recupera la posizione aperta per un simbolo specifico.
/// Ritorna null se non esiste nessuna posizione aperta per quel simbolo.
/// </summary>
Task<IPosition> GetPositionAsync(string symbol);
/// <summary>
/// Recupera l'ultimo prezzo di un simbolo
/// </summary>
Task<decimal> GetLatestPriceAsync(string symbol);
/// <summary>
/// Chiude tutte le posizioni aperte
/// </summary>
Task CloseAllPositionsAsync();
/// <summary>
/// Recupera gli ordini (aperti, chiusi o tutti)
/// </summary>
/// <param name="statusFilter">Filtro stato: Open, Closed, All</param>
/// <param name="limit">Numero massimo di ordini da recuperare</param>
Task<IReadOnlyList<IOrder>> GetOrdersAsync(OrderStatusFilter statusFilter = OrderStatusFilter.Open, int limit = 50);
/// <summary>
/// Cancella un ordine specifico per ID
/// </summary>
Task CancelOrderAsync(Guid orderId);
/// <summary>
/// Chiude una singola posizione aperta per simbolo
/// </summary>
Task ClosePositionAsync(string symbol);
/// <summary>
/// Recupera solo le posizioni aperte originate da ordini del bot
/// (riconosce gli ordini del bot tramite il prefisso BOT_ nel ClientOrderId).
/// Ritorna un sottoinsieme di GetAllPositionsAsync filtrato sui simboli
/// per cui esiste almeno un ordine buy filled con ClientOrderId che inizia per "BOT_".
/// </summary>
Task<IReadOnlyList<IPosition>> GetBotPositionsAsync();
/// <summary>
/// Cerca asset disponibili su Alpaca per simbolo o nome.
/// Ritorna al massimo <paramref name="maxResults"/> risultati tradabili.
/// </summary>
Task<List<AssetSearchResult>> SearchAssetsAsync(string query, int maxResults = 20);
}
}
+113
View File
@@ -0,0 +1,113 @@
using System;
namespace DesktopBot.Services
{
/// <summary>
/// Helper per determinare se un mercato è aperto in base all'asset class e all'orario locale.
///
/// Regole approssimative (senza chiamata API aggiuntiva):
/// - US Equity : LunVen 09:3016:00 ET (UTC-5 standard / UTC-4 daylight)
/// - Crypto : sempre aperto (24/7)
/// - Altro : assume sempre aperto
///
/// Per una verifica precisa (pre-market, post-market, festivi) usare
/// IAlpacaTradingClient.GetClockAsync() — disponibile ma costa una chiamata API.
/// </summary>
public static class MarketHoursService
{
// ── Orari NYSE/NASDAQ in Eastern Time ────────────────────────────────
private static readonly TimeSpan MarketOpen = new TimeSpan(9, 30, 0);
private static readonly TimeSpan MarketClose = new TimeSpan(16, 0, 0);
// Fuso Eastern (tiene conto automaticamente del DST di .NET)
private static readonly TimeZoneInfo EasternTz =
TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
/// <summary>
/// Ritorna true se il mercato relativo all'asset class indicata è
/// presumibilmente aperto adesso.
/// </summary>
public static bool IsMarketOpen(string assetClass)
{
if (string.IsNullOrEmpty(assetClass))
return true;
var cls = assetClass.ToLowerInvariant();
// Crypto: 24/7
if (cls.Contains("crypto"))
return true;
// US Equity: LunVen, orario NYSE
var nowEt = TimeZoneInfo.ConvertTime(DateTime.UtcNow, EasternTz);
if (nowEt.DayOfWeek == DayOfWeek.Saturday || nowEt.DayOfWeek == DayOfWeek.Sunday)
return false;
var tod = nowEt.TimeOfDay;
return tod >= MarketOpen && tod < MarketClose;
}
/// <summary>
/// Testo descrittivo dello stato mercato per la UI.
/// </summary>
public static string GetMarketStatusLabel(string assetClass)
{
if (string.IsNullOrEmpty(assetClass))
return "";
var cls = assetClass.ToLowerInvariant();
if (cls.Contains("crypto"))
return "Mercato 24/7 — sempre aperto";
var nowEt = TimeZoneInfo.ConvertTime(DateTime.UtcNow, EasternTz);
if (nowEt.DayOfWeek == DayOfWeek.Saturday || nowEt.DayOfWeek == DayOfWeek.Sunday)
return $"Mercato chiuso (weekend) — riapertura lunedì {MarketOpen:hh\\:mm} ET";
var tod = nowEt.TimeOfDay;
if (tod < MarketOpen)
return $"Mercato chiuso — apertura alle {MarketOpen:hh\\:mm} ET";
if (tod >= MarketClose)
return $"Mercato chiuso — riapertura domani {MarketOpen:hh\\:mm} ET";
var remaining = MarketClose - tod;
return $"Mercato aperto — chiude tra {(int)remaining.TotalHours}h {remaining.Minutes}m ET";
}
/// <summary>
/// Colore suggerito per il badge stato mercato.
/// </summary>
public static string GetMarketStatusColor(string assetClass)
=> IsMarketOpen(assetClass) ? "#00E676" : "#FFC107";
/// <summary>
/// Secondi da attendere prima della prossima apertura (utile per sleep nel loop).
/// Ritorna 0 se il mercato è già aperto.
/// </summary>
public static double SecondsUntilOpen(string assetClass)
{
if (IsMarketOpen(assetClass)) return 0;
var cls = (assetClass ?? "").ToLowerInvariant();
if (cls.Contains("crypto")) return 0;
var nowEt = TimeZoneInfo.ConvertTime(DateTime.UtcNow, EasternTz);
DateTime nextOpen;
if (nowEt.TimeOfDay < MarketOpen)
{
nextOpen = nowEt.Date + MarketOpen;
}
else
{
// Dopo la chiusura → prossimo giorno lavorativo
var candidate = nowEt.Date.AddDays(1) + MarketOpen;
while (candidate.DayOfWeek == DayOfWeek.Saturday || candidate.DayOfWeek == DayOfWeek.Sunday)
candidate = candidate.AddDays(1);
nextOpen = candidate;
}
var diff = nextOpen - nowEt;
return diff.TotalSeconds > 0 ? diff.TotalSeconds : 0;
}
}
}