Add more tests + Log pnl for each backtest

This commit is contained in:
2025-11-14 13:12:04 +07:00
parent 2548e9b757
commit d341ee05c9
11 changed files with 4163 additions and 97 deletions

View File

@@ -561,6 +561,40 @@ public static class TradingBox
return totalVolume;
}
public record AgentSummaryMetrics(
decimal TotalPnL,
decimal NetPnL,
decimal TotalROI,
decimal TotalVolume,
int Wins,
int Losses,
decimal TotalFees,
decimal Collateral);
public static AgentSummaryMetrics CalculateAgentSummaryMetrics(List<Position> positions)
{
var validPositions = positions?
.Where(p => p.IsValidForMetrics())
.ToList() ?? new List<Position>();
if (!validPositions.Any())
{
return new AgentSummaryMetrics(0m, 0m, 0m, 0m, 0, 0, 0m, 0m);
}
var totalPnL = validPositions.Sum(p => p.ProfitAndLoss?.Realized ?? 0m);
var totalFees = validPositions.Sum(p => p.CalculateTotalFees());
var netPnL = totalPnL - totalFees;
var totalVolume = GetTotalVolumeTraded(validPositions);
var wins = validPositions.Count(p => (p.ProfitAndLoss?.Net ?? 0m) > 0m);
var losses = validPositions.Count(p => (p.ProfitAndLoss?.Net ?? 0m) <= 0m);
var collateral = validPositions.Sum(p => (p.Open?.Price ?? 0m) * (p.Open?.Quantity ?? 0m));
var totalROI = collateral > 0m ? (netPnL / collateral) * 100m : 0m;
return new AgentSummaryMetrics(totalPnL, netPnL, totalROI, totalVolume, wins, losses, totalFees,
collateral);
}
/// <summary>
/// Calculates the volume traded in the last 24 hours
/// </summary>
@@ -749,7 +783,7 @@ public static class TradingBox
var buildedIndicator = ScenarioHelpers.BuildIndicator(ScenarioHelpers.BaseToLight(indicator));
indicatorsValues[indicator.Type] = buildedIndicator.GetIndicatorValues(candles);
}
catch (Exception ex)
catch (Exception)
{
// Removed logging for performance in static method
// Consider adding logging back if error handling is needed
@@ -980,4 +1014,177 @@ public static class TradingBox
return totalVolume;
}
#region TradingBot Calculations - Extracted from TradingBotBase for testability
/// <summary>
/// Calculates the position size (quantity * leverage)
/// </summary>
/// <param name="quantity">The quantity of the position</param>
/// <param name="leverage">The leverage multiplier</param>
/// <returns>The position size</returns>
public static decimal CalculatePositionSize(decimal quantity, decimal leverage)
{
return quantity * leverage;
}
/// <summary>
/// Calculates the profit and loss for a position based on entry/exit prices, quantity, leverage, and direction
/// </summary>
/// <param name="entryPrice">The entry price of the position</param>
/// <param name="exitPrice">The exit price of the position</param>
/// <param name="quantity">The quantity of the position</param>
/// <param name="leverage">The leverage multiplier</param>
/// <param name="direction">The trade direction (Long or Short)</param>
/// <returns>The calculated PnL</returns>
public static decimal CalculatePnL(decimal entryPrice, decimal exitPrice, decimal quantity, decimal leverage, TradeDirection direction)
{
var positionSize = CalculatePositionSize(quantity, leverage);
if (direction == TradeDirection.Long)
{
return (exitPrice - entryPrice) * positionSize;
}
else
{
return (entryPrice - exitPrice) * positionSize;
}
}
/// <summary>
/// Calculates the price difference based on direction
/// For Long: exitPrice - entryPrice
/// For Short: entryPrice - exitPrice
/// </summary>
/// <param name="entryPrice">The entry price</param>
/// <param name="exitPrice">The exit price</param>
/// <param name="direction">The trade direction</param>
/// <returns>The price difference</returns>
public static decimal CalculatePriceDifference(decimal entryPrice, decimal exitPrice, TradeDirection direction)
{
if (direction == TradeDirection.Long)
{
return exitPrice - entryPrice;
}
else
{
return entryPrice - exitPrice;
}
}
/// <summary>
/// Calculates the PnL percentage (ROI) based on current PnL, entry price, and quantity
/// Returns 0 if entry price * quantity is 0 to avoid division by zero
/// </summary>
/// <param name="pnl">The current profit and loss</param>
/// <param name="entryPrice">The entry price</param>
/// <param name="quantity">The quantity</param>
/// <returns>The PnL percentage rounded to 2 decimal places</returns>
public static decimal CalculatePnLPercentage(decimal pnl, decimal entryPrice, decimal quantity)
{
var denominator = entryPrice * quantity;
if (denominator == 0)
{
return 0;
}
return Math.Round((pnl / denominator) * 100, 2);
}
/// <summary>
/// Determines if a position is currently in profit based on entry price, current price, and direction
/// </summary>
/// <param name="entryPrice">The entry price</param>
/// <param name="currentPrice">The current market price</param>
/// <param name="direction">The trade direction</param>
/// <returns>True if position is in profit, false otherwise</returns>
public static bool IsPositionInProfit(decimal entryPrice, decimal currentPrice, TradeDirection direction)
{
if (direction == TradeDirection.Long)
{
return currentPrice > entryPrice;
}
else
{
return currentPrice < entryPrice;
}
}
/// <summary>
/// Calculates the cooldown end time based on last position closing time, timeframe, and cooldown period
/// </summary>
/// <param name="lastClosingTime">The time when the last position was closed</param>
/// <param name="timeframe">The trading timeframe</param>
/// <param name="cooldownPeriod">The cooldown period in candles</param>
/// <returns>The DateTime when the cooldown period ends</returns>
public static DateTime CalculateCooldownEndTime(DateTime lastClosingTime, Timeframe timeframe, int cooldownPeriod)
{
var baseIntervalSeconds = CandleHelpers.GetBaseIntervalInSeconds(timeframe);
return lastClosingTime.AddSeconds(baseIntervalSeconds * cooldownPeriod);
}
/// <summary>
/// Checks if a position has exceeded the maximum time limit
/// </summary>
/// <param name="openDate">The date when the position was opened</param>
/// <param name="currentTime">The current time</param>
/// <param name="maxHours">The maximum hours the position can be open (nullable, null or 0 means no limit)</param>
/// <returns>True if position has exceeded time limit, false otherwise</returns>
public static bool HasPositionExceededTimeLimit(DateTime openDate, DateTime currentTime, decimal? maxHours)
{
if (!maxHours.HasValue || maxHours.Value <= 0)
{
return false; // Time-based closure is disabled
}
var timeOpen = currentTime - openDate;
var maxTimeAllowed = TimeSpan.FromHours((double)maxHours.Value);
return timeOpen >= maxTimeAllowed;
}
/// <summary>
/// Checks if opening a new position should be blocked due to loss streak
/// Returns false (block) if:
/// - MaxLossStreak > 0 (limit is enabled)
/// - We have at least maxLossStreak recent finished positions
/// - All recent positions were losses
/// - The last position was in the same direction as the signal
/// </summary>
/// <param name="recentPositions">List of recent finished positions, ordered by date descending (most recent first)</param>
/// <param name="maxLossStreak">Maximum allowed loss streak (0 or negative means no limit)</param>
/// <param name="signalDirection">The direction of the signal for the new position</param>
/// <returns>True if position can be opened, false if blocked by loss streak</returns>
public static bool CheckLossStreak(List<Position> recentPositions, int maxLossStreak, TradeDirection signalDirection)
{
// If MaxLossStreak is 0, there's no limit
if (maxLossStreak <= 0)
{
return true;
}
// If we don't have enough positions to form a streak, we can open
if (recentPositions.Count < maxLossStreak)
{
return true;
}
// Check if all recent positions were losses
var allLosses = recentPositions.All(p => p.ProfitAndLoss?.Realized < 0);
if (!allLosses)
{
return true;
}
// If we have a loss streak, check if the last position was in the same direction as the signal
var lastPosition = recentPositions.First();
if (lastPosition.OriginDirection == signalDirection)
{
return false; // Block same direction after loss streak
}
return true;
}
#endregion
}