Sviluppo TradingBot
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user