Files
managing-apps/src/Managing.Application/Bots/SpotBot.cs
2025-12-04 21:21:48 +07:00

723 lines
30 KiB
C#

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;
}
// Ensure account is loaded before accessing Account.Exchange
if (Account == null)
{
Logger.LogWarning("Cannot update signals: Account is null. Loading account...");
await LoadAccountAsync();
if (Account == null)
{
Logger.LogError("Cannot update signals: Account failed to load");
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);
}
}
}
}