- Added a new method in IWeb3ProxyService to retrieve token balances directly from the blockchain, ensuring accurate decimal handling. - Updated ExchangeService to utilize the new on-chain balance method, replacing the previous balance retrieval logic. - Enhanced SpotBot logging to provide clearer warnings when token balances are significantly lower than expected, and to log cases of excess token balances. - Introduced a new API endpoint for fetching token balances on-chain, improving the overall functionality of the service.
1212 lines
55 KiB
C#
1212 lines
55 KiB
C#
#nullable enable
|
||
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
|
||
{
|
||
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 => await tradingService.GetPositionByIdentifierAsync(position.Identifier));
|
||
}
|
||
|
||
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 tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
if (tokenBalance is not { 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 => 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 => 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 tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
var hasOpenPosition = Positions.Values.Any(p => p.IsOpen());
|
||
|
||
if (hasOpenPosition)
|
||
{
|
||
// We have an internal position - verify it matches broker balance
|
||
if (tokenBalance is { 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if (tokenBalance is { Amount: > 0 })
|
||
{
|
||
// Check if this is a meaningful balance or just gas reserves / dust
|
||
// Minimum threshold: $10 USD value to be considered an orphaned position
|
||
const decimal minOrphanedBalanceValue = 10m;
|
||
|
||
if (tokenBalance.Value < minOrphanedBalanceValue)
|
||
{
|
||
await LogDebugAsync(
|
||
$"ℹ️ Small Token Balance Detected (Likely Gas Reserve or Dust)\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Token balance: `{tokenBalance.Amount:F8}`\n" +
|
||
$"USD Value: `${tokenBalance.Value:F2}`\n" +
|
||
$"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" +
|
||
$"Ignoring - safe to open new position");
|
||
return true; // Safe to open new position - this is just dust/gas reserve
|
||
}
|
||
|
||
// We have a significant token balance but no internal position - attempt to recover orphaned position
|
||
await LogWarningAsync(
|
||
$"⚠️ Orphaned Token Balance Detected\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Token balance: `{tokenBalance.Amount:F5}` (Value: ${tokenBalance.Value:F2})\n" +
|
||
$"Above orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" +
|
||
$"But no internal position tracked\n" +
|
||
$"Attempting to recover position from database...");
|
||
|
||
var recovered = await TryRecoverOrphanedPosition(tokenBalance.Amount);
|
||
if (!recovered)
|
||
{
|
||
await LogWarningAsync(
|
||
$"❌ Position Recovery Failed\n" +
|
||
$"Could not recover orphaned position\n" +
|
||
$"Manual cleanup may be required");
|
||
}
|
||
|
||
return false; // Don't allow opening new position until next cycle
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
private async Task<bool> TryRecoverOrphanedPosition(decimal tokenBalance)
|
||
{
|
||
try
|
||
{
|
||
// Get all positions for this bot from database
|
||
var allPositions = await ServiceScopeHelpers.WithScopedService<ITradingService, IEnumerable<Position>>(
|
||
_scopeFactory,
|
||
async tradingService => await tradingService.GetPositionsByInitiatorIdentifierAsync(Identifier));
|
||
|
||
// Calculate the maximum age for recovery (2 candles ago)
|
||
// Only recover positions that are recent enough to be legitimate orphaned positions
|
||
var candleIntervalSeconds = Managing.Domain.Candles.CandleHelpers.GetBaseIntervalInSeconds(Config.Timeframe);
|
||
var maxAgeSeconds = candleIntervalSeconds * 2; // 2 candles
|
||
var minDate = DateTime.UtcNow.AddSeconds(-maxAgeSeconds);
|
||
|
||
// Find the last position for this account and ticker that is:
|
||
// 1. Not Finished (Finished positions should not be recovered)
|
||
// 2. Less than 2 candles old
|
||
// 3. In a recoverable state (New, Cancelled, or Filled but not tracked)
|
||
var lastPosition = allPositions
|
||
.Where(p => p.AccountId == Account.Id &&
|
||
p.Ticker == Config.Ticker &&
|
||
p.Status != PositionStatus.Finished && // Never recover Finished positions
|
||
p.Date >= minDate) // Only check recent positions (less than 2 candles old)
|
||
.OrderByDescending(p => p.Date)
|
||
.FirstOrDefault();
|
||
|
||
if (lastPosition == null)
|
||
{
|
||
await LogWarningAsync(
|
||
$"🔍 No Recoverable Position Found\n" +
|
||
$"No recent unfinished position found in database for this ticker\n" +
|
||
$"Token balance: `{tokenBalance:F5}` may be from external source or old position\n" +
|
||
$"Only checking positions less than 2 candles old (max age: {maxAgeSeconds}s)");
|
||
return false;
|
||
}
|
||
|
||
// Verify that the Open trade exists
|
||
if (lastPosition.Open == null)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Invalid Position Data\n" +
|
||
$"Position `{lastPosition.Identifier}` has no Open trade\n" +
|
||
$"Cannot recover position");
|
||
return false;
|
||
}
|
||
|
||
// Verify that the token balance matches the position quantity with tolerance
|
||
var positionQuantity = lastPosition.Open.Quantity;
|
||
if (positionQuantity == 0)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Invalid Position Data\n" +
|
||
$"Position `{lastPosition.Identifier}` has zero quantity\n" +
|
||
$"Cannot recover position");
|
||
return false;
|
||
}
|
||
|
||
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage
|
||
var difference = Math.Abs(tokenBalance - positionQuantity);
|
||
|
||
if (difference > tolerance)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Token Balance Mismatch\n" +
|
||
$"Position: `{lastPosition.Identifier}`\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Token Balance: `{tokenBalance:F5}`\n" +
|
||
$"Difference: `{difference:F5}` exceeds tolerance `{tolerance:F5}`\n" +
|
||
$"Cannot recover - amounts don't match");
|
||
return false;
|
||
}
|
||
|
||
// Recover the position by setting status to Filled
|
||
var previousStatus = lastPosition.Status;
|
||
lastPosition.Status = PositionStatus.Filled;
|
||
lastPosition.Open.SetStatus(TradeStatus.Filled);
|
||
|
||
// Update quantity to match actual token balance
|
||
lastPosition.Open.Quantity = tokenBalance;
|
||
|
||
// Calculate current PnL
|
||
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
|
||
|
||
if (currentPrice > 0)
|
||
{
|
||
var openPrice = lastPosition.Open.Price;
|
||
var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance, 1,
|
||
TradeDirection.Long);
|
||
|
||
var totalFees = lastPosition.GasFees + lastPosition.UiFees;
|
||
var netPnl = pnlBeforeFees - totalFees;
|
||
|
||
lastPosition.ProfitAndLoss = new ProfitAndLoss
|
||
{
|
||
Realized = pnlBeforeFees,
|
||
Net = netPnl
|
||
};
|
||
}
|
||
|
||
// Update position in database
|
||
await UpdatePositionInDatabaseAsync(lastPosition);
|
||
|
||
// Add to internal positions collection
|
||
Positions[lastPosition.Identifier] = lastPosition;
|
||
|
||
// Update signal status if signal already exists
|
||
if (Signals.ContainsKey(lastPosition.SignalIdentifier))
|
||
{
|
||
SetSignalStatus(lastPosition.SignalIdentifier, SignalStatus.PositionOpen);
|
||
}
|
||
|
||
await LogWarningAsync(
|
||
$"✅ Orphaned Position Recovered\n" +
|
||
$"Position: `{lastPosition.Identifier}`\n" +
|
||
$"Signal: `{lastPosition.SignalIdentifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Previous Status: `{previousStatus}` → `Filled`\n" +
|
||
$"Token Balance: `{tokenBalance:F5}`\n" +
|
||
$"Open Price: `${lastPosition.Open.Price:F2}`\n" +
|
||
$"Open Date: `{lastPosition.Open.Date}`\n" +
|
||
$"Current Price: `${currentPrice:F2}`\n" +
|
||
$"Current PnL: `${lastPosition.ProfitAndLoss?.Realized ?? 0:F2}`\n" +
|
||
$"Position is now being tracked and will be managed on next cycle");
|
||
|
||
// Send notification
|
||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, lastPosition);
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error recovering orphaned position");
|
||
await LogWarningAsync($"❌ Position Recovery Error\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);
|
||
Account = account;
|
||
});
|
||
}
|
||
|
||
|
||
protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal)
|
||
{
|
||
// For spot trading, fetch token balance directly and verify/match with internal position
|
||
try
|
||
{
|
||
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
if (tokenBalance is { Amount: > 0 })
|
||
{
|
||
// Verify that the token balance matches the position amount
|
||
var positionQuantity = internalPosition.Open.Quantity;
|
||
var tokenBalanceAmount = tokenBalance.Amount;
|
||
|
||
if (positionQuantity > 0)
|
||
{
|
||
// Only check tolerance if token balance is LESS than position quantity
|
||
// If balance is greater, it could be orphaned tokens from previous positions
|
||
if (tokenBalanceAmount < positionQuantity)
|
||
{
|
||
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage
|
||
var difference = positionQuantity - tokenBalanceAmount;
|
||
|
||
if (difference > tolerance)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Token Balance Below Position Quantity\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
|
||
$"Difference: `{difference:F5}`\n" +
|
||
$"Tolerance (0.6%): `{tolerance:F5}`\n" +
|
||
$"Token balance is significantly lower than expected\n" +
|
||
$"Skipping position synchronization");
|
||
return; // Skip processing if balance is too low
|
||
}
|
||
}
|
||
else if (tokenBalanceAmount > positionQuantity)
|
||
{
|
||
// Token balance is higher than position - likely orphaned tokens
|
||
// Log but continue with synchronization
|
||
var excess = tokenBalanceAmount - positionQuantity;
|
||
await LogDebugAsync(
|
||
$"ℹ️ Token Balance Exceeds Position Quantity\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
|
||
$"Excess: `{excess:F5}`\n" +
|
||
$"Proceeding with position synchronization");
|
||
}
|
||
}
|
||
|
||
// Token balance exists and matches position - 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;
|
||
var quantityTolerance = internalPosition.Open.Quantity * 0.006m; // 0.6% tolerance for slippage
|
||
var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance);
|
||
|
||
if (quantityDifference > quantityTolerance)
|
||
{
|
||
await LogDebugAsync(
|
||
$"🔄 Token Balance Mismatch\n" +
|
||
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
|
||
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
|
||
$"Difference: `{quantityDifference:F5}`\n" +
|
||
$"Tolerance (0.6%): `{quantityTolerance: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 = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
|
||
_scopeFactory,
|
||
async exchangeService => 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)
|
||
{
|
||
var (positionFoundInHistory, hadWeb3ProxyError) =
|
||
await CheckSpotPositionInExchangeHistory(internalPosition);
|
||
|
||
if (hadWeb3ProxyError)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⏳ Web3Proxy Error During Spot Position Verification\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Cannot verify if position is closed\n" +
|
||
$"Will retry on next execution cycle");
|
||
return;
|
||
}
|
||
|
||
if (positionFoundInHistory)
|
||
{
|
||
internalPosition.Status = PositionStatus.Finished;
|
||
await HandleClosedPosition(internalPosition);
|
||
return;
|
||
}
|
||
|
||
// Position is Filled but no token balance and not found in history
|
||
// This could be a zombie position - check if token balance exists via direct exchange call
|
||
await LogWarningAsync(
|
||
$"⚠️ Potential Zombie Position Detected\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Status: Filled\n" +
|
||
$"Token balance: 0\n" +
|
||
$"Not found in exchange history\n" +
|
||
$"This position may have been closed externally or data is delayed\n" +
|
||
$"Will retry verification on next cycle");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error synchronizing position with token balance");
|
||
}
|
||
}
|
||
|
||
private async Task<(bool found, bool hadError)> CheckSpotPositionInExchangeHistory(Position position)
|
||
{
|
||
try
|
||
{
|
||
await LogDebugAsync(
|
||
$"🔍 Checking Spot Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
|
||
|
||
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
||
_scopeFactory,
|
||
async exchangeService =>
|
||
{
|
||
var fromDate = DateTime.UtcNow.AddHours(-24);
|
||
var toDate = DateTime.UtcNow;
|
||
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
||
});
|
||
|
||
if (positionHistory != null && positionHistory.Count != 0)
|
||
{
|
||
// Find a SELL/SHORT position that occurred AFTER our open position was created
|
||
// This indicates our LONG position was closed
|
||
var closingPosition = positionHistory
|
||
.Where(p => p.OriginDirection == TradeDirection.Short &&
|
||
p.Date >= position.Date)
|
||
.OrderByDescending(p => p.Date)
|
||
.FirstOrDefault();
|
||
|
||
if (closingPosition != null)
|
||
{
|
||
await LogDebugAsync(
|
||
$"✅ Closing Position Found in History\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Ticker: `{closingPosition.Ticker}`\n" +
|
||
$"Direction: `{closingPosition.OriginDirection}` (SHORT = Sold/Closed)\n" +
|
||
$"Date: `{closingPosition.Date}`\n" +
|
||
$"Our Position Open Date: `{position.Date}`");
|
||
return (true, false);
|
||
}
|
||
|
||
await LogDebugAsync(
|
||
$"ℹ️ No Closing Position Found in History\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Found {positionHistory.Count} history entries but none indicate position was closed\n" +
|
||
$"Position is likely still open");
|
||
}
|
||
else
|
||
{
|
||
await LogDebugAsync(
|
||
$"ℹ️ No Position History Available\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Position is likely still open");
|
||
}
|
||
|
||
return (false, false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error checking spot position history for position {PositionId}", position.Identifier);
|
||
await LogWarningAsync(
|
||
$"⚠️ Web3Proxy Error During Spot Position History Check\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Error: {ex.Message}\n" +
|
||
$"Will retry on next execution cycle");
|
||
return (false, true);
|
||
}
|
||
}
|
||
|
||
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 Task MonitorSynthRisk(LightSignal signal, Position position)
|
||
{
|
||
// Spot trading doesn't use Synth risk monitoring (futures-specific feature)
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
protected override Task<bool> RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal)
|
||
{
|
||
// Spot trading doesn't have broker positions to recover
|
||
// Positions are token balances, not tracked positions
|
||
return Task.FromResult(false);
|
||
}
|
||
|
||
protected override async Task<bool> ReconcileWithBrokerHistory(Position position, Candle currentCandle)
|
||
{
|
||
// Spot-specific: reconcile with spot position history
|
||
try
|
||
{
|
||
await LogDebugAsync(
|
||
$"🔍 Fetching Spot Position History\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
|
||
|
||
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
||
_scopeFactory,
|
||
async exchangeService =>
|
||
{
|
||
// Get position history from the last 24 hours for better coverage
|
||
var fromDate = DateTime.UtcNow.AddHours(-24);
|
||
var toDate = DateTime.UtcNow;
|
||
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
||
});
|
||
|
||
// Find the most recent position in history
|
||
if (positionHistory != null && positionHistory.Any())
|
||
{
|
||
// Get the most recent position from spot history (ordered by date)
|
||
var brokerPosition = positionHistory
|
||
.OrderByDescending(p => p.Open?.Date ?? p.Date)
|
||
.FirstOrDefault();
|
||
|
||
// For spot trading, SHORT direction means the spot was sold/closed
|
||
// We need to verify the last position is SHORT to confirm the position was correctly closed
|
||
if (brokerPosition != null && brokerPosition.OriginDirection == TradeDirection.Short)
|
||
{
|
||
if (brokerPosition.ProfitAndLoss != null)
|
||
{
|
||
await LogDebugAsync(
|
||
$"✅ Spot Position History Found\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Last Position Direction: `{brokerPosition.OriginDirection}` (SHORT = Sold/Closed) ✅\n" +
|
||
$"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" +
|
||
$"Bot's UI Fees: `${position.UiFees:F2}`\n" +
|
||
$"Bot's Gas Fees: `${position.GasFees:F2}`");
|
||
|
||
// Use the actual spot PnL data from broker history
|
||
// For spot, fees are simpler (no leverage, no closing UI fees)
|
||
var totalBotFees = position.GasFees + position.UiFees;
|
||
var brokerRealizedPnl = brokerPosition.ProfitAndLoss.Realized;
|
||
|
||
position.ProfitAndLoss = new ProfitAndLoss
|
||
{
|
||
Realized = brokerRealizedPnl,
|
||
Net = brokerRealizedPnl - totalBotFees
|
||
};
|
||
|
||
// Update the closing trade price if available
|
||
if (brokerPosition.Open != null)
|
||
{
|
||
var brokerClosingPrice = brokerPosition.Open.Price;
|
||
var isProfitable = position.OriginDirection == TradeDirection.Long
|
||
? position.Open.Price < brokerClosingPrice
|
||
: position.Open.Price > brokerClosingPrice;
|
||
|
||
if (isProfitable)
|
||
{
|
||
if (position.TakeProfit1 != null)
|
||
{
|
||
position.TakeProfit1.Price = brokerClosingPrice;
|
||
position.TakeProfit1.SetDate(brokerPosition.Open.Date);
|
||
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
||
}
|
||
|
||
// Cancel SL trade when TP is hit
|
||
if (position.StopLoss != null)
|
||
{
|
||
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (position.StopLoss != null)
|
||
{
|
||
position.StopLoss.Price = brokerClosingPrice;
|
||
position.StopLoss.SetDate(brokerPosition.Open.Date);
|
||
position.StopLoss.SetStatus(TradeStatus.Filled);
|
||
}
|
||
|
||
// Cancel TP trades when SL is hit
|
||
if (position.TakeProfit1 != null)
|
||
{
|
||
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
||
}
|
||
|
||
if (position.TakeProfit2 != null)
|
||
{
|
||
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
||
}
|
||
}
|
||
|
||
await LogDebugAsync(
|
||
$"📊 Spot Position Reconciliation Complete\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Closing Price: `${brokerClosingPrice:F2}`\n" +
|
||
$"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" +
|
||
$"PnL from broker: `${position.ProfitAndLoss.Realized:F2}`");
|
||
}
|
||
|
||
return true; // Successfully reconciled, skip candle-based calculation
|
||
}
|
||
}
|
||
else if (brokerPosition != null)
|
||
{
|
||
await LogDebugAsync(
|
||
$"⚠️ Last Position Not SHORT\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Last Position Direction: `{brokerPosition.OriginDirection}`\n" +
|
||
$"Expected SHORT to confirm spot was sold/closed\n" +
|
||
$"Will continue with candle-based calculation");
|
||
}
|
||
}
|
||
|
||
return false; // No matching position found or not reconciled, continue with candle-based calculation
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error reconciling spot position with broker history for position {PositionId}", position.Identifier);
|
||
await LogWarningAsync(
|
||
$"⚠️ Error During Spot Position History Reconciliation\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Error: {ex.Message}\n" +
|
||
$"Will continue with candle-based calculation");
|
||
return false; // On error, continue with candle-based calculation
|
||
}
|
||
}
|
||
|
||
protected override 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 != null)
|
||
{
|
||
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 Task.FromResult((closingPrice, pnlCalculated));
|
||
}
|
||
|
||
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());
|
||
|
||
// Create indicator combo config from user settings
|
||
var indicatorComboConfig = TradingBox.CreateConfigFromUserSettings(Account.User);
|
||
|
||
var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle, indicatorComboConfig);
|
||
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) =>
|
||
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 = await ServiceScopeHelpers
|
||
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
|
||
_scopeFactory, async (exchangeService, accountService, tradingService) =>
|
||
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
|
||
.Handle(command));
|
||
|
||
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped)
|
||
{
|
||
// Verify that token balance is cleared after closing (for live trading only)
|
||
if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot)
|
||
{
|
||
await VerifyTokenBalanceCleared(closedPosition);
|
||
}
|
||
|
||
if (tradeClosingPosition)
|
||
{
|
||
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
||
}
|
||
|
||
await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : 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 : null,
|
||
forceMarketClose);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task VerifyTokenBalanceCleared(Position closedPosition)
|
||
{
|
||
try
|
||
{
|
||
// Wait a short moment for the swap to complete on-chain
|
||
await Task.Delay(2000); // 2 seconds delay
|
||
|
||
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
// Token balance should be zero or very small (dust) after closing
|
||
var maxDustAmount = 0.0001m; // Consider amounts less than this as cleared
|
||
if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Token Balance Not Fully Cleared After Closing\n" +
|
||
$"Position: `{closedPosition.Identifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" +
|
||
$"Expected: `0` or less than `{maxDustAmount:F5}` (dust)\n" +
|
||
$"Attempting to force close remaining balance...");
|
||
|
||
// Attempt to force close the remaining balance
|
||
await ForceCloseRemainingBalance(closedPosition, tokenBalance.Amount);
|
||
}
|
||
else
|
||
{
|
||
await LogDebugAsync(
|
||
$"✅ Token Balance Verified Cleared\n" +
|
||
$"Position: `{closedPosition.Identifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Token Balance: `{tokenBalance?.Amount ?? 0:F5}`\n" +
|
||
$"Position successfully closed on exchange");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error verifying token balance cleared for position {PositionId}", closedPosition.Identifier);
|
||
// Don't throw - this is just a verification step
|
||
}
|
||
}
|
||
|
||
private async Task ForceCloseRemainingBalance(Position position, decimal remainingBalance)
|
||
{
|
||
try
|
||
{
|
||
// Prevent infinite retry loop - only attempt force close once
|
||
// The next bot cycle will handle verification
|
||
await LogInformationAsync(
|
||
$"🔄 Force Closing Remaining Balance (One-Time Attempt)\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Remaining Balance: `{remainingBalance:F5}`");
|
||
|
||
// Get current price for closing
|
||
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
|
||
|
||
if (currentPrice <= 0)
|
||
{
|
||
await LogWarningAsync(
|
||
$"❌ Cannot Force Close\n" +
|
||
$"Current price is invalid: `{currentPrice}`\n" +
|
||
$"Will retry on next cycle");
|
||
return;
|
||
}
|
||
|
||
// Create a new command to close the remaining balance
|
||
var retryCommand = new CloseSpotPositionCommand(position, position.AccountId, currentPrice);
|
||
|
||
Position retryClosedPosition = await ServiceScopeHelpers
|
||
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
|
||
_scopeFactory, async (exchangeService, accountService, tradingService) =>
|
||
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
|
||
.Handle(retryCommand));
|
||
|
||
if (retryClosedPosition != null &&
|
||
(retryClosedPosition.Status == PositionStatus.Finished || retryClosedPosition.Status == PositionStatus.Flipped))
|
||
{
|
||
await LogInformationAsync(
|
||
$"✅ Remaining Balance Force Closed Successfully\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Cleared Balance: `{remainingBalance:F5}`\n" +
|
||
$"Close Price: `${currentPrice:F2}`");
|
||
|
||
// Verify one more time that balance is now cleared (without triggering another force close)
|
||
await Task.Delay(2000); // Wait for swap to complete
|
||
|
||
var finalBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
if (finalBalance is { Amount: > 0.0001m })
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Balance Still Remaining After Force Close Attempt\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Remaining: `{finalBalance.Amount:F5}`\n" +
|
||
$"This will be handled on the next bot cycle\n" +
|
||
$"Manual intervention may be required if issue persists");
|
||
}
|
||
else
|
||
{
|
||
await LogInformationAsync(
|
||
$"✅ Balance Fully Cleared After Force Close\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Final Balance: `{finalBalance?.Amount ?? 0:F5}`");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error force closing remaining balance for position {PositionId}", position.Identifier);
|
||
await LogWarningAsync(
|
||
$"❌ Force Close Failed\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Error: {ex.Message}\n" +
|
||
$"Manual intervention may be required");
|
||
}
|
||
}
|
||
} |