Sviluppo TradingBot
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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 (0–100).</summary>
|
||||
public double MarketDataUsagePercent => MarketDataRpm * 100.0 / MarketDataRpmLimit;
|
||||
|
||||
/// <summary>Percentuale utilizzo finestra 60s per Trading (0–100).</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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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&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);
|
||||
}
|
||||
}
|
||||
@@ -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 : Lun–Ven 09:30–16: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: Lun–Ven, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user