Compare commits

..

10 Commits

Author SHA1 Message Date
0428775abf Excellent! Everything builds successfully. Let me create a summary of all the changes:
## Summary of Changes

I've successfully moved the `GetBacktestStats` endpoint to the `DataController` and removed JWT authentication. Here's what was done:

### 1. **Moved GetBacktestStats Endpoint to DataController** 
- **File**: `src/Managing.Api/Controllers/DataController.cs:1064`
- **Endpoint**: `GET /Data/GetBacktestStats/{id}`
- **Authentication**: None required (DataController has `[AllowAnonymous]`)
- Returns only statistical information without positions, signals, or candles

### 2. **Added IBacktester Dependency to DataController** 
- **File**: `src/Managing.Api/Controllers/DataController.cs:45,87`
- Added `IBacktester` field and constructor parameter
- Allows DataController to retrieve backtest information

### 3. **Created New Repository Method** 
- **Interface**: `src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs:41`
- **Implementation**: `src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs:301`
- Added `GetBacktestByIdAsync(string id)` - retrieves backtest without user filtering

### 4. **Created New Service Method** 
- **Interface**: `src/Managing.Application.Abstractions/Services/IBacktester.cs:67`
- **Implementation**: `src/Managing.Application/Backtests/Backtester.cs:221`
- Added `GetBacktestByIdAsync(string id)` in IBacktester service

### 5. **Removed Duplicate Endpoint from BacktestController** 
- **File**: `src/Managing.Api/Controllers/BacktestController.cs`
- Removed the `/Backtest/{id}/stats` endpoint to avoid duplication

### 6. **Regenerated Frontend API Client** 
- Successfully ran `dotnet build` in `src/Managing.Nswag`
- The new endpoint is now available in `ManagingApi.ts`

## API Endpoint Details

**Endpoint**: `GET /Data/GetBacktestStats/{id}`

**Authentication**: None (AllowAnonymous)

**Response Format**:
```json
{
  "id": "string",
  "name": "string",
  "ticker": "BTC",
  "timeframe": "15m",
  "tradingType": "Futures",
  "startDate": "2024-01-01T00:00:00Z",
  "endDate": "2024-12-31T23:59:59Z",
  "initialBalance": 1000,
  "finalPnl": 150.50,
  "netPnl": 145.25,
  "growthPercentage": 14.5,
  "hodlPercentage": 12.3,
  "winRate": 65,
  "sharpeRatio": 1.8,
  "maxDrawdown": -5.2,
  "maxDrawdownRecoveryTime": "2.00:00:00",
  "fees": 5.25,
  "score": 85.5,
  "scoreMessage": "Good performance",
  "positionCount": 150
}
```

All changes have been tested and the project builds successfully!
2026-01-09 19:18:52 +07:00
452c274073 Excellent! Let me create a comprehensive summary of all the changes made:
## Summary

I've successfully implemented all the requested features to add BacktestId support to the strategies/bot system:

### 1. **Added BacktestId Column to BotEntity** 
- **File**: `src/Managing.Infrastructure.Database/PostgreSql/Entities/BotEntity.cs:47`
- Added nullable `int? BacktestId` property with documentation
- This allows bots to reference the backtest they were created from

### 2. **Updated Bot Domain Model** 
- **File**: `src/Managing.Domain/Bots/Bot.cs:37`
- Added `BacktestId` property to the domain model
- Maintains consistency between entity and domain layers

### 3. **Updated TradingBotConfig and TradingBotConfigRequest** 
- **File**: `src/Managing.Domain/Bots/TradingBotConfig.cs:131`
  - Added `[Id(24)] public int? BacktestId` with Orleans serialization attribute
- **File**: `src/Managing.Domain/Bots/TradingBotConfigRequest.cs:119`
  - Added `BacktestId` property to the request model
- These changes ensure BacktestId flows through the entire bot creation pipeline

### 4. **Updated Data Mappers** 
- **File**: `src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs`
  - Updated `Map(Bot bot)` at line 833 to include BacktestId
  - Updated `Map(BotEntity entity)` at line 799 to include BacktestId
- Ensures proper mapping between entity and domain models

### 5. **Updated LiveTradingBotGrain** 
- **File**: `src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs`
  - Added BacktestId to bot creation at line 1078 (for saved bots)
  - Added BacktestId to bot creation at line 1145 (for running bots)
- The grain now properly persists BacktestId when saving bot statistics

### 6. **Created New Endpoint for Backtest Stats** 
- **File**: `src/Managing.Api/Controllers/BacktestController.cs:114`
- **New Endpoint**: `GET /Backtest/{id}/stats`
- Returns only statistical information without positions, signals, or candles:
  - Basic info: id, name, ticker, timeframe, tradingType, startDate, endDate
  - Performance: initialBalance, finalPnl, netPnl, growthPercentage, hodlPercentage, winRate
  - Risk metrics: sharpeRatio, maxDrawdown, maxDrawdownRecoveryTime
  - Other: fees, score, scoreMessage, positionCount

### 7. **Created Database Migration** 
- **Generated Migration**: `AddBacktestIdToBots`
- The migration adds a nullable integer column `BacktestId` to the `Bots` table
- Ready to be applied with `dotnet ef database update`

### 8. **Regenerated Frontend API Client** 
- Ran `dotnet build` in `src/Managing.Nswag`
- The `ManagingApi.ts` file has been regenerated with:
  - `backtestId` field in bot-related DTOs
  - New `/Backtest/{id}/stats` endpoint

## How It Works

### Starting a Bot from a Backtest:
1. Frontend sends `StartBotRequest` with `TradingBotConfigRequest` containing `backtestId`
2. `BotController` validates and prepares the request
3. `StartBotCommandHandler` creates the bot configuration with BacktestId
4. `LiveTradingBotGrain.CreateAsync()` receives the config and saves it to state
5. When the bot is saved via `SaveBotAsync()`, BacktestId is persisted to the database
6. The Bot entity now has a reference to its originating backtest

### Retrieving Backtest Stats:
1. Frontend calls `GET /Backtest/{id}/stats` with the backtest ID
2. Backend retrieves the full backtest from the database
3. Returns only the statistical summary (without heavy data like positions/signals/candles)
4. Frontend can display backtest performance metrics when viewing a bot

## Database Schema
```sql
ALTER TABLE "Bots" ADD COLUMN "BacktestId" integer NULL;
```

All changes follow the project's architecture patterns (Controller → Application → Repository) and maintain backward compatibility through nullable BacktestId fields.
2026-01-09 18:24:08 +07:00
1bb736ff70 Update OpenPositionCommandHandler and OpenSpotPositionCommandHandler to use consistent quantity for TakeProfit trades
- Modified the TakeProfit trade quantity assignment in both handlers to use position.Open.Quantity instead of the previously used quantity variable, ensuring consistency with StopLoss trades.
2026-01-09 05:19:15 +07:00
1d33c6c2ee Update AgentSummaryRepository to clarify BacktestCount management
- Added comments to indicate that BacktestCount is not updated directly in the entity, as it is managed independently via IncrementBacktestCountAsync. This change prevents other update operations from overwriting the BacktestCount, ensuring data integrity.
2026-01-09 04:27:58 +07:00
Oda
c4204f7264 Merge pull request #40 from CryptoOda/fix/spot-position-sync-dust-balance
Fix/spot position sync dust balance
2026-01-09 03:55:36 +07:00
07fb67c535 Enhance DataController to support status filtering in GetStrategiesPaginated method
- Added an optional status parameter to the GetStrategiesPaginated method, defaulting to Running if not provided.
- Updated the bot retrieval logic to apply the status filter directly, simplifying the filtering process and ensuring accurate bot status management.
2026-01-09 03:54:20 +07:00
ae353aa0d5 Implement balance update callback in TradingBotBase for immediate sync
- Removed the private _currentBalance field and replaced it with direct access to Config.BotTradingBalance.
- Added OnBalanceUpdatedCallback to TradingBotBase for immediate synchronization and database saving when the balance is updated.
- Updated LiveTradingBotGrain to set the callback for balance updates, ensuring accurate state management.
- Modified PostgreSqlBotRepository to save the updated bot trading balance during entity updates.
2026-01-09 03:34:35 +07:00
8d4be59d10 Update ProfitAndLoss calculation in BotController and DataController to use NetPnL
- Changed the ProfitAndLoss property assignment from item.Pnl to item.NetPnL in both BotController and DataController, ensuring consistency in profit and loss reporting across the application.
2026-01-08 07:01:49 +07:00
3f1d102452 Enhance SpotBot to verify opening swaps and handle failed transactions
- Added logic to confirm the existence of opening swaps in exchange history before marking positions as filled, addressing potential failures in on-chain transactions.
- Implemented checks for very low token balances and adjusted position statuses accordingly, ensuring accurate tracking and management of positions.
- Improved logging for failed swaps to provide clearer insights into transaction issues and position management.
2026-01-08 05:40:06 +07:00
efb1f2edce Enhance SpotBot to handle low token balances and improve position verification
- Added logic to check for very low token balances (dust) and verify closed positions in exchange history before logging warnings.
- Improved warning logging to avoid redundancy for dust amounts, ensuring accurate tracking of token balance issues.
2026-01-08 02:42:28 +07:00
21 changed files with 2119 additions and 39 deletions

View File

@@ -539,7 +539,7 @@ public class BotController : BaseController
WinRate = (item.TradeWins + item.TradeLosses) != 0
? item.TradeWins / (item.TradeWins + item.TradeLosses)
: 0,
ProfitAndLoss = item.Pnl,
ProfitAndLoss = item.NetPnL,
Roi = item.Roi,
Identifier = item.Identifier.ToString(),
AgentName = item.User.AgentName,

View File

@@ -42,6 +42,7 @@ public class DataController : ControllerBase
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IBotService _botService;
private readonly IConfiguration _configuration;
private readonly IBacktester _backtester;
/// <summary>
/// Initializes a new instance of the <see cref="DataController"/> class.
@@ -57,6 +58,7 @@ public class DataController : ControllerBase
/// <param name="serviceScopeFactory">Service scope factory for creating scoped services.</param>
/// <param name="botService">Service for bot operations.</param>
/// <param name="configuration">Configuration for accessing environment variables.</param>
/// <param name="backtester">Service for backtest operations.</param>
public DataController(
IExchangeService exchangeService,
IAccountService accountService,
@@ -68,7 +70,8 @@ public class DataController : ControllerBase
IGrainFactory grainFactory,
IServiceScopeFactory serviceScopeFactory,
IBotService botService,
IConfiguration configuration)
IConfiguration configuration,
IBacktester backtester)
{
_exchangeService = exchangeService;
_accountService = accountService;
@@ -81,6 +84,7 @@ public class DataController : ControllerBase
_serviceScopeFactory = serviceScopeFactory;
_botService = botService;
_configuration = configuration;
_backtester = backtester;
}
/// <summary>
@@ -918,6 +922,7 @@ public class DataController : ControllerBase
public async Task<ActionResult<PaginatedResponse<TradingBotResponse>>> GetStrategiesPaginated(
int pageNumber = 1,
int pageSize = 10,
BotStatus? status = null,
string? name = null,
string? ticker = null,
string? agentName = null,
@@ -943,11 +948,14 @@ public class DataController : ControllerBase
// Check environment variable for filtering profitable strategies only
var showOnlyProfitable = _configuration.GetValue<bool>("showOnlyProfitable", false);
// Get paginated bots excluding Saved status
// Default to Running status if not provided
var statusFilter = status ?? BotStatus.Running;
// Get paginated bots with status filter
var (bots, totalCount) = await _botService.GetBotsPaginatedAsync(
pageNumber,
pageSize,
null, // No specific status filter - we'll exclude Saved in the service call
statusFilter,
name,
ticker,
agentName,
@@ -957,9 +965,9 @@ public class DataController : ControllerBase
sortDirection,
showOnlyProfitable);
// Filter out Saved status bots
var filteredBots = bots.Where(bot => bot.Status != BotStatus.Saved).ToList();
var filteredCount = totalCount - bots.Count(bot => bot.Status == BotStatus.Saved);
// No additional filtering needed since we're using the status filter directly
var filteredBots = bots.ToList();
var filteredCount = totalCount;
// Map to response objects
var tradingBotResponses = MapBotsToTradingBotResponse(filteredBots);
@@ -1028,7 +1036,7 @@ public class DataController : ControllerBase
WinRate = (item.TradeWins + item.TradeLosses) != 0
? item.TradeWins / (item.TradeWins + item.TradeLosses)
: 0,
ProfitAndLoss = item.Pnl,
ProfitAndLoss = item.NetPnL,
Roi = item.Roi,
Identifier = item.Identifier.ToString(),
AgentName = item.User.AgentName,
@@ -1044,4 +1052,50 @@ public class DataController : ControllerBase
return list;
}
/// <summary>
/// Retrieves only the statistical information for a specific backtest by ID.
/// This endpoint returns only the performance metrics without positions, signals, or candles.
/// Useful for displaying backtest stats when starting a bot from a backtest.
/// No authentication required.
/// </summary>
/// <param name="id">The ID of the backtest to retrieve stats for.</param>
/// <returns>The backtest statistics without detailed position/signal data.</returns>
[HttpGet("GetBacktestStats/{id}")]
public async Task<ActionResult<object>> GetBacktestStats(int id)
{
var backtest = await _backtester.GetBacktestByIdAsync(id.ToString());
if (backtest == null)
{
return NotFound($"Backtest with ID {id} not found.");
}
// Return only the statistical information
var stats = new
{
id = backtest.Id,
name = backtest.Config.Name,
ticker = backtest.Config.Ticker,
timeframe = backtest.Config.Timeframe,
tradingType = backtest.Config.TradingType,
startDate = backtest.StartDate,
endDate = backtest.EndDate,
initialBalance = backtest.InitialBalance,
finalPnl = backtest.FinalPnl,
netPnl = backtest.NetPnl,
growthPercentage = backtest.GrowthPercentage,
hodlPercentage = backtest.HodlPercentage,
winRate = backtest.WinRate,
sharpeRatio = backtest.Statistics?.SharpeRatio ?? 0,
maxDrawdown = backtest.Statistics?.MaxDrawdown ?? 0,
maxDrawdownRecoveryTime = backtest.Statistics?.MaxDrawdownRecoveryTime ?? TimeSpan.Zero,
fees = backtest.Fees,
score = backtest.Score,
scoreMessage = backtest.ScoreMessage,
positionCount = backtest.PositionCount
};
return Ok(stats);
}
}

View File

@@ -38,6 +38,7 @@ public interface IBacktestRepository
BacktestsFilter? filter = null);
Task<Backtest> GetBacktestByIdForUserAsync(User user, string id);
Task<Backtest> GetBacktestByIdAsync(string id);
Task DeleteBacktestByIdForUserAsync(User user, string id);
Task DeleteBacktestsByIdsForUserAsync(User user, IEnumerable<string> ids);
void DeleteAllBacktestsForUser(User user);

View File

@@ -64,6 +64,7 @@ namespace Managing.Application.Abstractions.Services
(IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(Guid requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc");
Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByRequestIdPaginatedAsync(Guid requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc");
Task<Backtest> GetBacktestByIdForUserAsync(User user, string id);
Task<Backtest> GetBacktestByIdAsync(string id);
Task<bool> DeleteBacktestByUserAsync(User user, string id);
Task<bool> DeleteBacktestsByIdsForUserAsync(User user, IEnumerable<string> ids);
bool DeleteBacktestsByUser(User user);

View File

@@ -218,6 +218,16 @@ namespace Managing.Application.Backtests
return backtest;
}
public async Task<Backtest> GetBacktestByIdAsync(string id)
{
var backtest = await _backtestRepository.GetBacktestByIdAsync(id);
if (backtest == null)
return null;
return backtest;
}
public async Task<bool> DeleteBacktestByUserAsync(User user, string id)
{
try

View File

@@ -593,6 +593,18 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
// Load state into the trading bot instance
LoadStateIntoTradingBot(tradingBot);
// Set up callback for immediate balance sync and save when balance is updated
tradingBot.OnBalanceUpdatedCallback = async () =>
{
SyncStateFromBase();
await _state.WriteStateAsync();
// Save to database immediately so GetStrategiesPaginated shows correct balance
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
var status = await botRegistry.GetBotStatus(this.GetPrimaryKey());
await SaveBotAsync(status);
};
return tradingBot;
}
@@ -1063,6 +1075,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
Volume = 0,
Fees = 0,
BotTradingBalance = _state.State.Config.BotTradingBalance,
BacktestId = _state.State.Config.BacktestId,
MasterBotUserId = _state.State.Config.MasterBotUserId
};
}
@@ -1129,6 +1142,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
LongPositionCount = longPositionCount,
ShortPositionCount = shortPositionCount,
BotTradingBalance = _state.State.Config.BotTradingBalance,
BacktestId = _state.State.Config.BacktestId,
MasterBotUserId = _state.State.Config.MasterBotUserId
};
}

View File

@@ -454,6 +454,75 @@ public class SpotBot : TradingBotBase
if (positionQuantity > 0)
{
// Check if token balance is very low (dust) compared to position quantity
// This likely means the position was closed but status wasn't updated
var dustThreshold = Config.Ticker == Ticker.ETH
? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust
: 0.0001m; // Other tokens: very small amount is dust
var isDustAmount = tokenBalanceAmount <= dustThreshold;
var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected
// If balance is dust or very low, check if opening swap failed or position was closed
if (isDustAmount || balanceIsVeryLow)
{
if (internalPosition.Status == PositionStatus.Filled)
{
// First, check if the opening swap actually succeeded (exists in history)
// If not, the swap likely failed and position should be marked as Canceled
var openingSwapConfirmed = await VerifyOpeningSwapInHistory(internalPosition);
if (!openingSwapConfirmed)
{
// Opening swap not found in history - likely failed
// Mark position as Canceled since it never actually opened
var previousStatus = internalPosition.Status;
internalPosition.Status = PositionStatus.Canceled;
internalPosition.Open.SetStatus(TradeStatus.Cancelled);
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Canceled);
await UpdatePositionInDatabaseAsync(internalPosition);
await LogWarningAsync(
$"❌ Position Opening Failed - Swap Not Found in History\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Signal: `{internalPosition.SignalIdentifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Expected Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" +
$"Status Changed: `{previousStatus}` → `Canceled`\n" +
$"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" +
$"This indicates the swap failed and position never opened\n" +
$"Position will not be tracked or managed");
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - position failed to open
}
// Opening swap exists, so position did open - check if it was closed
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)
{
// Position was closed - mark as Finished
internalPosition.Status = PositionStatus.Finished;
await HandleClosedPosition(internalPosition);
return;
}
}
}
// 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)
@@ -462,6 +531,9 @@ public class SpotBot : TradingBotBase
var difference = positionQuantity - tokenBalanceAmount;
if (difference > tolerance)
{
// Only log warning if this is not a dust amount (already handled above)
if (!isDustAmount && !balanceIsVeryLow)
{
await LogWarningAsync(
$"⚠️ Token Balance Below Position Quantity\n" +
@@ -472,6 +544,7 @@ public class SpotBot : TradingBotBase
$"Tolerance (0.7%): `{tolerance:F5}`\n" +
$"Token balance is significantly lower than expected\n" +
$"Skipping position synchronization");
}
return; // Skip processing if balance is too low
}
}
@@ -490,16 +563,57 @@ public class SpotBot : TradingBotBase
}
}
// Token balance exists - verify the opening swap actually succeeded in history before marking as Filled
// Token balance exists - ALWAYS verify the opening swap actually succeeded in history
// This is critical because Web3Proxy may return success when transaction is sent,
// but the swap might fail on-chain. We must verify it actually executed.
var previousPositionStatus = internalPosition.Status;
// Only check history if position is not already Filled
if (internalPosition.Status != PositionStatus.Filled)
{
// Verify the opening swap actually executed successfully
// Always verify the opening swap exists in history, even if position is already marked as Filled
// This catches cases where the position was prematurely marked as Filled by the command handler
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition);
if (!swapConfirmedInHistory)
{
// Swap not found in history - check if this is a failed swap or just delayed
// If balance is very low, the swap likely failed
var dustThreshold = Config.Ticker == Ticker.ETH
? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust
: 0.0001m; // Other tokens: very small amount is dust
var isDustAmount = tokenBalanceAmount <= dustThreshold;
var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected
if (isDustAmount || balanceIsVeryLow)
{
// Balance is very low and swap not in history - swap likely failed
// Mark position as Canceled since swap failed on-chain
var previousStatus = internalPosition.Status;
internalPosition.Status = PositionStatus.Canceled;
internalPosition.Open.SetStatus(TradeStatus.Cancelled);
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Canceled);
await UpdatePositionInDatabaseAsync(internalPosition);
await LogWarningAsync(
$"❌ Position Opening Failed - Swap Not Found in History\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Signal: `{internalPosition.SignalIdentifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Expected Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" +
$"Status Changed: `{previousStatus}` → `Canceled`\n" +
$"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" +
$"This indicates the swap transaction failed on-chain even though it was sent\n" +
$"Position will not be tracked or managed");
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - swap failed
}
else
{
// Balance exists but swap not yet in history - might be delayed
// Only wait if position is not already Filled (to avoid repeated checks)
if (internalPosition.Status != PositionStatus.Filled)
{
await LogDebugAsync(
$"⏳ Opening Swap Not Yet Confirmed in History\n" +
@@ -510,6 +624,8 @@ public class SpotBot : TradingBotBase
$"Will retry on next cycle");
return; // Don't mark as Filled yet, wait for history confirmation
}
// If already Filled, continue - might be a timing issue with history indexing
}
}
// Position confirmed on broker (token balance exists AND swap confirmed in history)

View File

@@ -40,7 +40,6 @@ public abstract class TradingBotBase : ITradingBot
public Dictionary<string, LightSignal> Signals { get; set; }
public Dictionary<Guid, Position> Positions { get; set; }
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
private decimal _currentBalance;
public DateTime PreloadSince { get; set; }
public int PreloadedCandlesCount { get; set; }
public long ExecutionCount { get; set; } = 0;
@@ -51,6 +50,12 @@ public abstract class TradingBotBase : ITradingBot
// OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls
private bool _hasOpenPosition = false;
/// <summary>
/// Callback to notify the grain when balance is updated (for immediate sync and save to database).
/// Set by the grain after creating the bot instance.
/// </summary>
public Func<Task>? OnBalanceUpdatedCallback { get; set; }
public TradingBotBase(
ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory,
@@ -65,7 +70,6 @@ public abstract class TradingBotBase : ITradingBot
Signals = new Dictionary<string, LightSignal>();
Positions = new Dictionary<Guid, Position>();
WalletBalances = new Dictionary<DateTime, decimal>();
_currentBalance = config.BotTradingBalance;
PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe);
}
@@ -433,13 +437,13 @@ public abstract class TradingBotBase : ITradingBot
if (WalletBalances.Count == 0)
{
WalletBalances[date] = _currentBalance;
WalletBalances[date] = Config.BotTradingBalance;
return;
}
if (!WalletBalances.ContainsKey(date))
{
WalletBalances[date] = _currentBalance;
WalletBalances[date] = Config.BotTradingBalance;
}
}
@@ -1270,13 +1274,15 @@ public abstract class TradingBotBase : ITradingBot
if (position.ProfitAndLoss != null)
{
// Update the current balance when position closes
_currentBalance += position.ProfitAndLoss.Net;
// Update the balance when position closes
Config.BotTradingBalance += position.ProfitAndLoss.Net;
await LogDebugAsync(
string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`",
Config.BotTradingBalance));
// For live trading, immediately sync and save to database
await OnBalanceUpdatedAsync();
}
}
else
@@ -2060,6 +2066,18 @@ public abstract class TradingBotBase : ITradingBot
await CancelAllOrders();
}
/// <summary>
/// Called when the bot trading balance is updated (e.g., after a position closes).
/// Calls the OnBalanceUpdatedCallback if set (by the grain for live trading).
/// </summary>
protected virtual async Task OnBalanceUpdatedAsync()
{
if (OnBalanceUpdatedCallback != null)
{
await OnBalanceUpdatedCallback();
}
}
// Interface implementation
public async Task LogInformation(string message)
{

View File

@@ -123,7 +123,7 @@ namespace Managing.Application.Trading.Handlers
position.TakeProfit1 = exchangeService.BuildEmptyTrade(
request.Ticker,
takeProfitPrice,
quantity,
position.Open.Quantity, // Use same quantity as StopLoss for consistency
closeDirection,
request.MoneyManagement.Leverage,
TradeType.TakeProfit,

View File

@@ -121,7 +121,7 @@ public class OpenSpotPositionCommandHandler(
position.TakeProfit1 = exchangeService.BuildEmptyTrade(
request.Ticker,
takeProfitPrice,
quantity,
position.Open.Quantity, // Use same quantity as StopLoss for consistency
TradeDirection.Short,
1, // Spot trading has no leverage
TradeType.TakeProfit,

View File

@@ -31,6 +31,11 @@ namespace Managing.Domain.Bots
public decimal BotTradingBalance { get; set; }
/// <summary>
/// The backtest ID associated with this bot (nullable for bots not created from backtests)
/// </summary>
public int? BacktestId { get; set; }
/// <summary>
/// The user ID of the master bot's owner when this bot is for copy trading
/// </summary>

View File

@@ -123,4 +123,10 @@ public class TradingBotConfig
/// </summary>
[Id(23)]
public int? MasterBotUserId { get; set; }
/// <summary>
/// The backtest ID associated with this bot configuration (nullable for bots not created from backtests)
/// </summary>
[Id(24)]
public int? BacktestId { get; set; }
}

View File

@@ -112,4 +112,9 @@ public class TradingBotConfigRequest
public bool UseForDynamicStopLoss { get; set; } = true;
public TradingType TradingType { get; set; }
/// <summary>
/// The backtest ID associated with this bot configuration (nullable for bots not created from backtests)
/// </summary>
public int? BacktestId { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Managing.Infrastructure.Databases.Migrations
{
/// <inheritdoc />
public partial class AddBacktestIdToBots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BacktestId",
table: "Bots",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BacktestId",
table: "Bots");
}
}
}

View File

@@ -305,6 +305,9 @@ namespace Managing.Infrastructure.Databases.Migrations
b.Property<long>("AccumulatedRunTimeSeconds")
.HasColumnType("bigint");
b.Property<int?>("BacktestId")
.HasColumnType("integer");
b.Property<decimal>("BotTradingBalance")
.HasColumnType("numeric");

View File

@@ -276,7 +276,8 @@ public class AgentSummaryRepository : IAgentSummaryRepository
entity.TotalVolume = domain.TotalVolume;
entity.TotalBalance = domain.TotalBalance;
entity.TotalFees = domain.TotalFees;
entity.BacktestCount = domain.BacktestCount;
// BacktestCount is NOT updated here - it's managed independently via IncrementBacktestCountAsync
// This prevents other update operations from overwriting the BacktestCount
}
private static AgentSummary MapToDomain(AgentSummaryEntity entity)

View File

@@ -41,6 +41,11 @@ public class BotEntity
public decimal BotTradingBalance { get; set; }
/// <summary>
/// The backtest ID associated with this bot (nullable for bots not created from backtests)
/// </summary>
public int? BacktestId { get; set; }
/// <summary>
/// The user ID of the master bot's owner when this bot is for copy trading
/// </summary>

View File

@@ -298,6 +298,17 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
return entity != null ? PostgreSqlMappers.Map(entity) : null;
}
public async Task<Backtest> GetBacktestByIdAsync(string id)
{
var entity = await _context.Backtests
.AsNoTracking()
.Include(b => b.User)
.FirstOrDefaultAsync(b => b.Identifier == id)
.ConfigureAwait(false);
return entity != null ? PostgreSqlMappers.Map(entity) : null;
}
public void DeleteBacktestByIdForUser(User user, string id)
{
var entity = _context.Backtests

View File

@@ -83,6 +83,7 @@ public class PostgreSqlBotRepository : IBotRepository
existingEntity.AccumulatedRunTimeSeconds = bot.AccumulatedRunTimeSeconds;
existingEntity.MasterBotUserId =
bot.MasterBotUserId ?? existingEntity.MasterBotUserId;
existingEntity.BotTradingBalance = bot.BotTradingBalance;
await _context.SaveChangesAsync().ConfigureAwait(false);
}

View File

@@ -796,6 +796,7 @@ public static class PostgreSqlMappers
LongPositionCount = entity.LongPositionCount,
ShortPositionCount = entity.ShortPositionCount,
BotTradingBalance = entity.BotTradingBalance,
BacktestId = entity.BacktestId,
MasterBotUserId = entity.MasterBotUserId,
MasterBotUser = entity.MasterBotUser != null ? Map(entity.MasterBotUser) : null
};
@@ -830,6 +831,7 @@ public static class PostgreSqlMappers
LongPositionCount = bot.LongPositionCount,
ShortPositionCount = bot.ShortPositionCount,
BotTradingBalance = bot.BotTradingBalance,
BacktestId = bot.BacktestId,
MasterBotUserId = bot.MasterBotUserId,
UpdatedAt = DateTime.UtcNow
};