Add spot trading
This commit is contained in:
@@ -531,7 +531,14 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
|
||||
var tradingBot = new FuturesBot(logger, _scopeFactory, config, streamProvider);
|
||||
|
||||
// Create the trading bot instance based on TradingType
|
||||
TradingBotBase tradingBot = config.TradingType switch
|
||||
{
|
||||
TradingType.Futures => new FuturesBot(logger, _scopeFactory, config, streamProvider),
|
||||
TradingType.Spot => new SpotBot(logger, _scopeFactory, config, streamProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported TradingType for live trading: {config.TradingType}")
|
||||
};
|
||||
|
||||
// Load state into the trading bot instance
|
||||
LoadStateIntoTradingBot(tradingBot);
|
||||
|
||||
710
src/Managing.Application/Bots/SpotBot.cs
Normal file
710
src/Managing.Application/Bots/SpotBot.cs
Normal file
@@ -0,0 +1,710 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Trading.Commands;
|
||||
using Managing.Application.Trading.Handlers;
|
||||
using Managing.Core;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Indicators;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Strategies.Base;
|
||||
using Managing.Domain.Trades;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Orleans.Streams;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots;
|
||||
|
||||
public class SpotBot : TradingBotBase, ITradingBot
|
||||
{
|
||||
public SpotBot(
|
||||
ILogger<TradingBotBase> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TradingBotConfig config,
|
||||
IStreamProvider? streamProvider = null
|
||||
) : base(logger, scopeFactory, config, streamProvider)
|
||||
{
|
||||
// Spot trading mode - ensure it's not backtest
|
||||
Config.TradingType = TradingType.Spot;
|
||||
}
|
||||
|
||||
// SpotBot uses the base implementation for Start() which includes
|
||||
// account loading, balance verification, and live trading startup messages
|
||||
|
||||
public override async Task Run()
|
||||
{
|
||||
// Live trading signal update logic
|
||||
if (!Config.IsForCopyTrading)
|
||||
{
|
||||
await UpdateSignals();
|
||||
}
|
||||
|
||||
await LoadLastCandle();
|
||||
|
||||
if (!Config.IsForWatchingOnly)
|
||||
{
|
||||
await ManagePositions();
|
||||
}
|
||||
|
||||
UpdateWalletBalances();
|
||||
|
||||
// Live trading execution logging
|
||||
ExecutionCount++;
|
||||
|
||||
Logger.LogInformation(
|
||||
"[Spot][{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}",
|
||||
Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name,
|
||||
DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count);
|
||||
|
||||
Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName,
|
||||
string.Join(", ",
|
||||
Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}")));
|
||||
}
|
||||
|
||||
protected override async Task<Position> GetInternalPositionForUpdate(Position position)
|
||||
{
|
||||
// For live trading, get position from database via trading service
|
||||
return await ServiceScopeHelpers.WithScopedService<ITradingService, Position>(
|
||||
_scopeFactory,
|
||||
async tradingService => { return await tradingService.GetPositionByIdentifierAsync(position.Identifier); });
|
||||
}
|
||||
|
||||
protected override async Task<List<Position>> GetBrokerPositionsForUpdate(Account account)
|
||||
{
|
||||
// For live spot trading, we don't have broker positions like futures
|
||||
// Positions are verified via token balances in UpdatePositionWithBrokerData and SynchronizeWithBrokerPositions
|
||||
// Return empty list - actual verification happens in those methods
|
||||
return new List<Position>();
|
||||
}
|
||||
|
||||
protected override async Task UpdatePositionWithBrokerData(Position position, List<Position> brokerPositions)
|
||||
{
|
||||
// For spot trading, fetch token balance directly and update PnL based on current price
|
||||
try
|
||||
{
|
||||
var balances = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Balance>>(
|
||||
_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetBalances(Account); });
|
||||
|
||||
// Find the token balance for the ticker
|
||||
var tickerString = Config.Ticker.ToString();
|
||||
var tokenBalance = balances.FirstOrDefault(b =>
|
||||
b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (tokenBalance == null || tokenBalance.Amount <= 0)
|
||||
{
|
||||
// No token balance found - position might be closed
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current price from LastCandle
|
||||
var currentPrice = LastCandle?.Close ?? 0;
|
||||
if (currentPrice == 0)
|
||||
{
|
||||
// Try to get current price from exchange
|
||||
currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
|
||||
_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); });
|
||||
}
|
||||
|
||||
if (currentPrice == 0)
|
||||
{
|
||||
Logger.LogWarning("Cannot update PnL: current price is 0");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate PnL based on current token balance and current price
|
||||
// For LONG spot position: PnL = (currentPrice - openPrice) * tokenBalance
|
||||
var openPrice = position.Open.Price;
|
||||
var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance.Amount, 1, TradeDirection.Long);
|
||||
|
||||
// Update position PnL
|
||||
UpdatePositionPnl(position.Identifier, pnlBeforeFees);
|
||||
|
||||
var totalFees = position.GasFees + position.UiFees;
|
||||
var netPnl = pnlBeforeFees - totalFees;
|
||||
|
||||
if (position.ProfitAndLoss == null)
|
||||
{
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl };
|
||||
}
|
||||
else
|
||||
{
|
||||
position.ProfitAndLoss.Realized = pnlBeforeFees;
|
||||
position.ProfitAndLoss.Net = netPnl;
|
||||
}
|
||||
|
||||
await LogDebugAsync(
|
||||
$"📊 Spot Position PnL Updated\n" +
|
||||
$"Position: `{position.Identifier}`\n" +
|
||||
$"Token Balance: `{tokenBalance.Amount:F5}`\n" +
|
||||
$"Open Price: `${openPrice:F2}`\n" +
|
||||
$"Current Price: `${currentPrice:F2}`\n" +
|
||||
$"PnL (before fees): `${pnlBeforeFees:F2}`\n" +
|
||||
$"Net PnL: `${netPnl:F2}`");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error updating position PnL from token balance");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<Candle> GetCurrentCandleForPositionClose(Account account, string ticker)
|
||||
{
|
||||
// For live trading, get real-time candle from exchange
|
||||
return await ServiceScopeHelpers.WithScopedService<IExchangeService, Candle>(_scopeFactory,
|
||||
async exchangeService =>
|
||||
{
|
||||
return await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<bool> CheckBrokerPositions()
|
||||
{
|
||||
// For spot trading, check token balances to verify position status
|
||||
try
|
||||
{
|
||||
var balances = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Balance>>(
|
||||
_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetBalances(Account); });
|
||||
|
||||
var tickerString = Config.Ticker.ToString();
|
||||
var tokenBalance = balances.FirstOrDefault(b =>
|
||||
b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
var hasOpenPosition = Positions.Values.Any(p => p.IsOpen());
|
||||
|
||||
if (hasOpenPosition)
|
||||
{
|
||||
// We have an internal position - verify it matches broker balance
|
||||
if (tokenBalance != null && tokenBalance.Amount > 0)
|
||||
{
|
||||
await LogDebugAsync(
|
||||
$"✅ Spot Position Verified\n" +
|
||||
$"Ticker: {Config.Ticker}\n" +
|
||||
$"Internal position: Open\n" +
|
||||
$"Token balance: `{tokenBalance.Amount:F5}`\n" +
|
||||
$"Position matches broker balance");
|
||||
return false; // Position already open, cannot open new one
|
||||
}
|
||||
else
|
||||
{
|
||||
await LogWarningAsync(
|
||||
$"⚠️ Position Mismatch\n" +
|
||||
$"Ticker: {Config.Ticker}\n" +
|
||||
$"Internal position exists but no token balance found\n" +
|
||||
$"Position may need synchronization");
|
||||
return false; // Don't allow opening new position until resolved
|
||||
}
|
||||
}
|
||||
else if (tokenBalance != null && tokenBalance.Amount > 0)
|
||||
{
|
||||
// We have a token balance but no internal position - orphaned position
|
||||
await LogWarningAsync(
|
||||
$"⚠️ Orphaned Token Balance Detected\n" +
|
||||
$"Ticker: {Config.Ticker}\n" +
|
||||
$"Token balance: `{tokenBalance.Amount:F5}`\n" +
|
||||
$"But no internal position tracked\n" +
|
||||
$"This may require manual cleanup");
|
||||
return false; // Don't allow opening new position until resolved
|
||||
}
|
||||
|
||||
// No position and no balance - safe to open
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogWarningAsync($"❌ Broker Position Check Failed\nError checking token balances\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task LoadAccountAsync()
|
||||
{
|
||||
// Live trading: load real account from database
|
||||
if (Config.TradingType == TradingType.BacktestSpot) return;
|
||||
await ServiceScopeHelpers.WithScopedService<IAccountService>(_scopeFactory, async accountService =>
|
||||
{
|
||||
var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false);
|
||||
Account = account;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task VerifyAndUpdateBalanceAsync()
|
||||
{
|
||||
// Live trading: verify real USDC balance for spot trading
|
||||
if (Config.TradingType == TradingType.BacktestSpot) return;
|
||||
|
||||
try
|
||||
{
|
||||
var actualBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
|
||||
async exchangeService =>
|
||||
{
|
||||
var balances = await exchangeService.GetBalances(Account);
|
||||
var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC");
|
||||
return usdcBalance?.Amount ?? 0;
|
||||
});
|
||||
|
||||
if (actualBalance < Config.BotTradingBalance)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"Actual USDC balance ({ActualBalance:F2}) is less than configured balance ({ConfiguredBalance:F2}). Updating configuration.",
|
||||
actualBalance, Config.BotTradingBalance);
|
||||
|
||||
var newConfig = Config;
|
||||
newConfig.BotTradingBalance = actualBalance;
|
||||
await UpdateConfiguration(newConfig);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error verifying and updating balance");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal,
|
||||
List<Position> brokerPositions)
|
||||
{
|
||||
// For spot trading, fetch token balance directly and verify/match with internal position
|
||||
try
|
||||
{
|
||||
var balances = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Balance>>(
|
||||
_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetBalances(Account); });
|
||||
|
||||
// Find the token balance for the ticker
|
||||
var tickerString = Config.Ticker.ToString();
|
||||
var tokenBalance = balances.FirstOrDefault(b =>
|
||||
b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (tokenBalance != null && tokenBalance.Amount > 0)
|
||||
{
|
||||
// Token balance exists - verify position is filled
|
||||
var previousPositionStatus = internalPosition.Status;
|
||||
|
||||
// Position found on broker (token balance exists), means the position is filled
|
||||
// Update position status
|
||||
internalPosition.Status = PositionStatus.Filled;
|
||||
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled);
|
||||
|
||||
// Update Open trade status
|
||||
internalPosition.Open.SetStatus(TradeStatus.Filled);
|
||||
positionForSignal.Open.SetStatus(TradeStatus.Filled);
|
||||
|
||||
// Update quantity to match actual token balance
|
||||
var actualTokenBalance = tokenBalance.Amount;
|
||||
if (Math.Abs(internalPosition.Open.Quantity - actualTokenBalance) > 0.0001m)
|
||||
{
|
||||
await LogDebugAsync(
|
||||
$"🔄 Token Balance Mismatch\n" +
|
||||
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
|
||||
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
|
||||
$"Updating to match broker balance");
|
||||
internalPosition.Open.Quantity = actualTokenBalance;
|
||||
positionForSignal.Open.Quantity = actualTokenBalance;
|
||||
}
|
||||
|
||||
// Calculate and update PnL based on current price
|
||||
var currentPrice = LastCandle?.Close ?? 0;
|
||||
if (currentPrice == 0)
|
||||
{
|
||||
currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
|
||||
_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); });
|
||||
}
|
||||
|
||||
if (currentPrice > 0)
|
||||
{
|
||||
var openPrice = internalPosition.Open.Price;
|
||||
var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, actualTokenBalance, 1, TradeDirection.Long);
|
||||
UpdatePositionPnl(positionForSignal.Identifier, pnlBeforeFees);
|
||||
|
||||
var totalFees = internalPosition.GasFees + internalPosition.UiFees;
|
||||
var netPnl = pnlBeforeFees - totalFees;
|
||||
internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl };
|
||||
}
|
||||
|
||||
await UpdatePositionInDatabaseAsync(internalPosition);
|
||||
|
||||
if (previousPositionStatus != PositionStatus.Filled &&
|
||||
internalPosition.Status == PositionStatus.Filled)
|
||||
{
|
||||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, internalPosition);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No token balance found - check if position was closed
|
||||
if (internalPosition.Status == PositionStatus.Filled)
|
||||
{
|
||||
await LogDebugAsync(
|
||||
$"⚠️ Position Status Check\n" +
|
||||
$"Internal position `{internalPosition.Identifier}` shows Filled\n" +
|
||||
$"But no token balance found on broker\n" +
|
||||
$"Position may have been closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error synchronizing position with token balance");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
|
||||
Position positionForSignal)
|
||||
{
|
||||
// Spot trading doesn't use orders like futures - positions are opened via swaps
|
||||
// Just check if the swap was successful
|
||||
if (internalPosition.Status == PositionStatus.New)
|
||||
{
|
||||
// Check if swap was successful by verifying position status
|
||||
// For spot, if Open trade is Filled, the position is filled
|
||||
if (positionForSignal.Open?.Status == TradeStatus.Filled)
|
||||
{
|
||||
internalPosition.Status = PositionStatus.Filled;
|
||||
await SetPositionStatus(signal.Identifier, PositionStatus.Filled);
|
||||
await UpdatePositionInDatabaseAsync(internalPosition);
|
||||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task MonitorSynthRisk(LightSignal signal, Position position)
|
||||
{
|
||||
// Spot trading doesn't use Synth risk monitoring (futures-specific feature)
|
||||
return;
|
||||
}
|
||||
|
||||
protected override async Task<bool> RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal)
|
||||
{
|
||||
// Spot trading doesn't have broker positions to recover
|
||||
// Positions are token balances, not tracked positions
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async Task<bool> ReconcileWithBrokerHistory(Position position, Candle currentCandle)
|
||||
{
|
||||
// Spot trading doesn't have broker position history like futures
|
||||
// Return false to continue with candle-based calculation
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles(
|
||||
Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice)
|
||||
{
|
||||
decimal closingPrice = 0;
|
||||
bool pnlCalculated = false;
|
||||
|
||||
if (forceMarketClose && forcedClosingPrice.HasValue)
|
||||
{
|
||||
closingPrice = forcedClosingPrice.Value;
|
||||
|
||||
bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long
|
||||
? closingPrice > position.Open.Price
|
||||
: closingPrice < position.Open.Price;
|
||||
|
||||
if (isManualCloseProfitable)
|
||||
{
|
||||
if (position.TakeProfit1 != null)
|
||||
{
|
||||
position.TakeProfit1.Price = closingPrice;
|
||||
position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow);
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
||||
}
|
||||
|
||||
if (position.StopLoss != null)
|
||||
{
|
||||
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (position.StopLoss != null)
|
||||
{
|
||||
position.StopLoss.Price = closingPrice;
|
||||
position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow);
|
||||
position.StopLoss.SetStatus(TradeStatus.Filled);
|
||||
}
|
||||
|
||||
if (position.TakeProfit1 != null)
|
||||
{
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
|
||||
if (position.TakeProfit2 != null)
|
||||
{
|
||||
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
pnlCalculated = true;
|
||||
}
|
||||
else if (currentCandle != null)
|
||||
{
|
||||
// For spot trading, check if SL/TP was hit using candle data
|
||||
if (position.OriginDirection == TradeDirection.Long)
|
||||
{
|
||||
if (position.StopLoss.Price >= currentCandle.Low)
|
||||
{
|
||||
closingPrice = position.StopLoss.Price;
|
||||
position.StopLoss.SetDate(currentCandle.Date);
|
||||
position.StopLoss.SetStatus(TradeStatus.Filled);
|
||||
|
||||
if (position.TakeProfit1 != null)
|
||||
{
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
|
||||
if (position.TakeProfit2 != null)
|
||||
{
|
||||
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
else if (position.TakeProfit1.Price <= currentCandle.High &&
|
||||
position.TakeProfit1.Status != TradeStatus.Filled)
|
||||
{
|
||||
closingPrice = position.TakeProfit1.Price;
|
||||
position.TakeProfit1.SetDate(currentCandle.Date);
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
||||
|
||||
if (position.StopLoss != null)
|
||||
{
|
||||
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closingPrice == 0)
|
||||
{
|
||||
// Manual/exchange close - use current candle close
|
||||
closingPrice = currentCandle.Close;
|
||||
|
||||
bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long
|
||||
? closingPrice > position.Open.Price
|
||||
: closingPrice < position.Open.Price;
|
||||
|
||||
if (isManualCloseProfitable)
|
||||
{
|
||||
position.TakeProfit1.SetPrice(closingPrice, 2);
|
||||
position.TakeProfit1.SetDate(currentCandle.Date);
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
||||
|
||||
if (position.StopLoss != null)
|
||||
{
|
||||
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
position.StopLoss.SetPrice(closingPrice, 2);
|
||||
position.StopLoss.SetDate(currentCandle.Date);
|
||||
position.StopLoss.SetStatus(TradeStatus.Filled);
|
||||
|
||||
if (position.TakeProfit1 != null)
|
||||
{
|
||||
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
|
||||
if (position.TakeProfit2 != null)
|
||||
{
|
||||
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pnlCalculated = true;
|
||||
}
|
||||
|
||||
return (closingPrice, pnlCalculated);
|
||||
}
|
||||
|
||||
protected override async Task<decimal> GetLastPriceForPositionOpeningAsync()
|
||||
{
|
||||
// For live trading, get current price from exchange
|
||||
return await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
|
||||
async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); });
|
||||
}
|
||||
|
||||
protected override async Task UpdateSignalsCore(IReadOnlyList<Candle> candles,
|
||||
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
|
||||
{
|
||||
// For spot trading, always fetch signals regardless of open positions
|
||||
// Check if we're in cooldown period
|
||||
if (await IsInCooldownPeriodAsync())
|
||||
{
|
||||
// Still in cooldown period, skip signal generation
|
||||
return;
|
||||
}
|
||||
|
||||
// Live trading: use ScenarioRunnerGrain to get signals
|
||||
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
||||
{
|
||||
var scenarioRunnerGrain = grainFactory.GetGrain<IScenarioRunnerGrain>(Guid.NewGuid());
|
||||
var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle);
|
||||
if (signal == null) return;
|
||||
await AddSignal(signal);
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<bool> CanOpenPosition(LightSignal signal)
|
||||
{
|
||||
// For spot trading, only LONG signals can open positions
|
||||
if (signal.Direction != TradeDirection.Long)
|
||||
{
|
||||
await LogInformationAsync(
|
||||
$"🚫 Short Signal Ignored\nShort signals cannot open positions in spot trading\nSignal: `{signal.Identifier}` will be ignored");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Early return if bot hasn't executed first cycle yet
|
||||
if (ExecutionCount == 0)
|
||||
{
|
||||
await LogInformationAsync("⏳ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check broker positions for live trading
|
||||
var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal);
|
||||
if (!canOpenPosition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cooldown period and loss streak
|
||||
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
|
||||
}
|
||||
|
||||
protected override async Task<Position> HandleFlipPosition(LightSignal signal, Position openedPosition,
|
||||
LightSignal previousSignal, decimal lastPrice)
|
||||
{
|
||||
// For spot trading, SHORT signals should close the open LONG position
|
||||
// LONG signals should not flip (they would be same direction)
|
||||
if (signal.Direction == TradeDirection.Short && openedPosition.OriginDirection == TradeDirection.Long)
|
||||
{
|
||||
// SHORT signal closes the open LONG position
|
||||
await LogInformationAsync(
|
||||
$"🔻 Short Signal - Closing Long Position\nClosing position `{openedPosition.Identifier}` due to SHORT signal\nSignal: `{signal.Identifier}`");
|
||||
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
|
||||
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished);
|
||||
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
||||
return null; // No new position opened for SHORT signals
|
||||
}
|
||||
else if (signal.Direction == TradeDirection.Long && openedPosition.OriginDirection == TradeDirection.Long)
|
||||
{
|
||||
// Same direction LONG signal - ignore it
|
||||
await LogInformationAsync(
|
||||
$"📍 Same Direction Signal\nLONG signal `{signal.Identifier}` ignored\nPosition `{openedPosition.Identifier}` already open for LONG");
|
||||
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This shouldn't happen in spot trading, but handle it gracefully
|
||||
await LogInformationAsync(
|
||||
$"⚠️ Unexpected Signal Direction\nSignal: `{signal.Identifier}` Direction: `{signal.Direction}`\nPosition: `{openedPosition.Identifier}` Direction: `{openedPosition.OriginDirection}`\nSignal ignored");
|
||||
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<Position> ExecuteOpenPosition(LightSignal signal, decimal lastPrice)
|
||||
{
|
||||
// Spot-specific position opening: includes balance verification and live exchange calls
|
||||
if (signal.Direction != TradeDirection.Long)
|
||||
{
|
||||
throw new InvalidOperationException($"Only LONG signals can open positions in spot trading. Received: {signal.Direction}");
|
||||
}
|
||||
|
||||
if (Account == null || Account.User == null)
|
||||
{
|
||||
throw new InvalidOperationException("Account and Account.User must be set before opening a position");
|
||||
}
|
||||
|
||||
// Verify actual balance before opening position
|
||||
await VerifyAndUpdateBalanceAsync();
|
||||
|
||||
var command = new OpenSpotPositionRequest(
|
||||
Config.AccountName,
|
||||
Config.MoneyManagement,
|
||||
signal.Direction,
|
||||
Config.Ticker,
|
||||
PositionInitiator.Bot,
|
||||
signal.Date,
|
||||
Account.User,
|
||||
Config.BotTradingBalance,
|
||||
isForPaperTrading: false, // Spot is live trading
|
||||
lastPrice,
|
||||
signalIdentifier: signal.Identifier,
|
||||
initiatorIdentifier: Identifier,
|
||||
tradingType: Config.TradingType);
|
||||
|
||||
var position = await ServiceScopeHelpers
|
||||
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
|
||||
_scopeFactory,
|
||||
async (exchangeService, accountService, tradingService) =>
|
||||
{
|
||||
return await new OpenSpotPositionCommandHandler(exchangeService, accountService, tradingService)
|
||||
.Handle(command);
|
||||
});
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
public override async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice,
|
||||
bool tradeClosingPosition = false, bool forceMarketClose = false)
|
||||
{
|
||||
await LogInformationAsync(
|
||||
$"🔧 Closing {position.OriginDirection} Spot Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity:F5}`");
|
||||
|
||||
// Live spot trading: close position via swap
|
||||
var command = new CloseSpotPositionCommand(position, position.AccountId, lastPrice);
|
||||
try
|
||||
{
|
||||
Position closedPosition = null;
|
||||
await ServiceScopeHelpers.WithScopedServices<IExchangeService, IAccountService, ITradingService>(
|
||||
_scopeFactory, async (exchangeService, accountService, tradingService) =>
|
||||
{
|
||||
closedPosition =
|
||||
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
|
||||
.Handle(command);
|
||||
});
|
||||
|
||||
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped)
|
||||
{
|
||||
if (tradeClosingPosition)
|
||||
{
|
||||
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
||||
}
|
||||
|
||||
await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : (decimal?)null,
|
||||
forceMarketClose);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Wrong position status : {closedPosition.Status}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogWarningAsync($"Position {signal.Identifier} not closed : {ex.Message}");
|
||||
|
||||
if (position.Status == PositionStatus.Canceled || position.Status == PositionStatus.Rejected)
|
||||
{
|
||||
// Trade close on exchange => Should close trade manually
|
||||
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
||||
// Ensure trade dates are properly updated even for canceled/rejected positions
|
||||
await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null,
|
||||
forceMarketClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +625,11 @@ public static class Enums
|
||||
/// <summary>
|
||||
/// Backtest spot trading mode
|
||||
/// </summary>
|
||||
BacktestSpot
|
||||
BacktestSpot,
|
||||
|
||||
/// <summary>
|
||||
/// Live spot trading mode
|
||||
/// </summary>
|
||||
Spot
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user