951 lines
36 KiB
C#
951 lines
36 KiB
C#
using Exilion.TradingAtomics;
|
|
using Managing.Common;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Candles;
|
|
using Managing.Domain.Indicators;
|
|
using Managing.Domain.MoneyManagements;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Statistics;
|
|
using Managing.Domain.Strategies;
|
|
using Managing.Domain.Strategies.Base;
|
|
using Managing.Domain.Trades;
|
|
using static Managing.Common.Enums;
|
|
|
|
namespace Managing.Domain.Shared.Helpers;
|
|
|
|
/// <summary>
|
|
/// Configuration for strategy combination logic
|
|
/// </summary>
|
|
public class IndicatorComboConfig
|
|
{
|
|
/// <summary>
|
|
/// Minimum percentage of trend strategies that must agree for strong trend (default: 66%)
|
|
/// </summary>
|
|
public decimal TrendStrongAgreementThreshold { get; set; } = 0.66m;
|
|
|
|
/// <summary>
|
|
/// Minimum percentage of signal strategies that must agree (default: 50%)
|
|
/// </summary>
|
|
public decimal SignalAgreementThreshold { get; set; } = 0.5m;
|
|
|
|
/// <summary>
|
|
/// Whether to allow signal strategies to override conflicting trends (default: true)
|
|
/// This is useful for trend reversal signals
|
|
/// </summary>
|
|
public bool AllowSignalTrendOverride { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Minimum confidence level to return a signal (default: Low)
|
|
/// </summary>
|
|
public Confidence MinimumConfidence { get; set; } = Confidence.Low;
|
|
|
|
/// <summary>
|
|
/// Minimum confidence level required from context strategies (default: Medium)
|
|
/// Context strategies evaluate market conditions - higher requirements mean more conservative trading
|
|
/// </summary>
|
|
public Confidence MinimumContextConfidence { get; set; } = Confidence.Medium;
|
|
|
|
/// <summary>
|
|
/// Default exchange to use when signals don't specify one
|
|
/// </summary>
|
|
public TradingExchanges DefaultExchange { get; set; } = TradingExchanges.Binance;
|
|
}
|
|
|
|
public static class TradingBox
|
|
{
|
|
private static readonly IndicatorComboConfig _defaultConfig = new();
|
|
|
|
public static LightSignal GetSignal(HashSet<Candle> newCandles, LightScenario scenario,
|
|
Dictionary<string, LightSignal> previousSignal, int? loopbackPeriod = 1)
|
|
{
|
|
return GetSignal(newCandles, scenario, previousSignal, _defaultConfig, loopbackPeriod, null);
|
|
}
|
|
|
|
public static LightSignal GetSignal(HashSet<Candle> newCandles, LightScenario scenario,
|
|
Dictionary<string, LightSignal> previousSignal, int? loopbackPeriod,
|
|
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues)
|
|
{
|
|
return GetSignal(newCandles, scenario, previousSignal, _defaultConfig, loopbackPeriod,
|
|
preCalculatedIndicatorValues);
|
|
}
|
|
|
|
public static LightSignal GetSignal(HashSet<Candle> newCandles, LightScenario lightScenario,
|
|
Dictionary<string, LightSignal> previousSignal, IndicatorComboConfig config, int? loopbackPeriod = 1)
|
|
{
|
|
return GetSignal(newCandles, lightScenario, previousSignal, config, loopbackPeriod, null);
|
|
}
|
|
|
|
public static LightSignal GetSignal(HashSet<Candle> newCandles, LightScenario lightScenario,
|
|
Dictionary<string, LightSignal> previousSignal, IndicatorComboConfig config, int? loopbackPeriod,
|
|
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues)
|
|
{
|
|
var signalOnCandles = new List<LightSignal>();
|
|
|
|
foreach (var indicator in lightScenario.Indicators)
|
|
{
|
|
IIndicator indicatorInstance = indicator.ToInterface();
|
|
|
|
// Use pre-calculated indicator values if available (for backtest optimization)
|
|
List<LightSignal> signals;
|
|
if (preCalculatedIndicatorValues != null && preCalculatedIndicatorValues.ContainsKey(indicator.Type))
|
|
{
|
|
// Use pre-calculated values to avoid recalculating indicators
|
|
signals = indicatorInstance.Run(newCandles, preCalculatedIndicatorValues[indicator.Type]);
|
|
}
|
|
else
|
|
{
|
|
// Normal path: calculate indicators on the fly
|
|
signals = indicatorInstance.Run(newCandles);
|
|
}
|
|
|
|
// Optimized: Use Count property instead of Count() LINQ method
|
|
if (signals == null || signals.Count == 0)
|
|
{
|
|
// For trend and context strategies, lack of signal might be meaningful
|
|
// Signal strategies are expected to be sparse, so we continue
|
|
if (indicator.SignalType == SignalType.Signal)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// For trend strategies, no signal might mean neutral trend
|
|
// For context strategies, no signal might mean no restrictions
|
|
// We'll let the ComputeSignals method handle these cases
|
|
continue;
|
|
}
|
|
|
|
var limitedCandles = newCandles.ToList();
|
|
// Optimized: limitedCandles is already ordered, no need to re-order
|
|
var loopback = loopbackPeriod.HasValue && loopbackPeriod > 1 ? loopbackPeriod.Value : 1;
|
|
var candleLoopback = limitedCandles.Count > loopback
|
|
? limitedCandles.Skip(limitedCandles.Count - loopback).ToList()
|
|
: limitedCandles;
|
|
|
|
if (!candleLoopback.Any())
|
|
{
|
|
// Handle empty case (e.g., log warning, skip processing)
|
|
continue;
|
|
}
|
|
|
|
var loopbackStartDate = candleLoopback.First().Date;
|
|
|
|
foreach (var signal in signals.Where(s => s.Date >= loopbackStartDate))
|
|
{
|
|
var hasExistingSignal = previousSignal.ContainsKey(signal.Identifier);
|
|
if (!hasExistingSignal)
|
|
{
|
|
bool shouldAdd = previousSignal.Count == 0 || previousSignal.Values.Last().Date < signal.Date;
|
|
if (shouldAdd)
|
|
{
|
|
signalOnCandles.Add(signal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep only the latest signal per indicator to avoid count mismatch
|
|
var latestSignalsPerIndicator = signalOnCandles
|
|
.GroupBy(s => s.IndicatorName)
|
|
.Select(g => g.OrderByDescending(s => s.Date).First())
|
|
.ToHashSet();
|
|
|
|
// Remove the restrictive requirement that ALL strategies must produce signals
|
|
// Instead, let ComputeSignals handle the logic based on what we have
|
|
if (!latestSignalsPerIndicator.Any())
|
|
{
|
|
return null; // No signals from any strategy
|
|
}
|
|
|
|
var data = newCandles.First();
|
|
return ComputeSignals(lightScenario, latestSignalsPerIndicator, data.Ticker,
|
|
data.Timeframe, config);
|
|
}
|
|
|
|
public static LightSignal ComputeSignals(LightScenario scenario, HashSet<LightSignal> signalOnCandles,
|
|
Ticker ticker,
|
|
Timeframe timeframe)
|
|
{
|
|
return ComputeSignals(scenario, signalOnCandles, ticker, timeframe, _defaultConfig);
|
|
}
|
|
|
|
public static LightSignal ComputeSignals(LightScenario scenario, HashSet<LightSignal> signalOnCandles,
|
|
Ticker ticker,
|
|
Timeframe timeframe, IndicatorComboConfig config)
|
|
{
|
|
if (scenario.Indicators.Count == 1)
|
|
{
|
|
// Only one strategy, return the single signal
|
|
return signalOnCandles.Single();
|
|
}
|
|
|
|
// Optimized: Sort only if needed, then convert to HashSet
|
|
var orderedSignals = signalOnCandles.OrderBy(s => s.Date).ToList();
|
|
signalOnCandles = new HashSet<LightSignal>(orderedSignals);
|
|
|
|
// Check if all strategies produced signals - this is required for composite signals
|
|
var strategyNames = scenario.Indicators.Select(s => s.Name).ToHashSet();
|
|
var signalIndicatorNames = signalOnCandles.Select(s => s.IndicatorName).ToHashSet();
|
|
|
|
if (!strategyNames.SetEquals(signalIndicatorNames))
|
|
{
|
|
// Not all strategies produced signals - composite signal requires all strategies to contribute
|
|
return null;
|
|
}
|
|
|
|
// Group signals by type for analysis
|
|
var signals = signalOnCandles.Where(s => s.SignalType == SignalType.Signal).ToList();
|
|
var trendSignals = signalOnCandles.Where(s => s.SignalType == SignalType.Trend).ToList();
|
|
var contextSignals = signalOnCandles.Where(s => s.SignalType == SignalType.Context).ToList();
|
|
|
|
// Context validation - evaluates market conditions based on confidence levels
|
|
if (!ValidateContextStrategies(scenario, contextSignals, config))
|
|
{
|
|
return null; // Context strategies are blocking the trade
|
|
}
|
|
|
|
// Check for 100% agreement across ALL signals (no threshold voting)
|
|
var allDirectionalSignals = signalOnCandles
|
|
.Where(s => s.Direction != TradeDirection.None && s.SignalType != SignalType.Context).ToList();
|
|
|
|
if (!allDirectionalSignals.Any())
|
|
{
|
|
return null; // No directional signals available
|
|
}
|
|
|
|
// Require 100% agreement - all signals must have the same direction
|
|
var lastSignalDirection = allDirectionalSignals.Last().Direction;
|
|
if (!allDirectionalSignals.All(s => s.Direction == lastSignalDirection))
|
|
{
|
|
return null; // Signals are not in complete agreement
|
|
}
|
|
|
|
var finalDirection = lastSignalDirection;
|
|
|
|
// Calculate confidence based on the average confidence of all signals
|
|
var averageConfidence = CalculateAverageConfidence(allDirectionalSignals);
|
|
|
|
if (finalDirection == TradeDirection.None || averageConfidence < config.MinimumConfidence)
|
|
{
|
|
return null; // No valid signal or below minimum confidence
|
|
}
|
|
|
|
// Create composite signal
|
|
var lastSignal = signals.LastOrDefault() ??
|
|
trendSignals.LastOrDefault() ?? contextSignals.LastOrDefault();
|
|
|
|
return new LightSignal(
|
|
ticker,
|
|
finalDirection,
|
|
averageConfidence,
|
|
lastSignal?.Candle,
|
|
lastSignal?.Date ?? DateTime.UtcNow,
|
|
lastSignal?.Exchange ?? config.DefaultExchange,
|
|
IndicatorType.Composite,
|
|
SignalType.Signal, "Aggregated");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the average confidence level from a list of signals
|
|
/// </summary>
|
|
private static Confidence CalculateAverageConfidence(List<LightSignal> signals)
|
|
{
|
|
if (!signals.Any())
|
|
{
|
|
return Confidence.None;
|
|
}
|
|
|
|
// Convert confidence enum to numeric values for averaging
|
|
var confidenceValues = signals.Select(s => (int)s.Confidence).ToList();
|
|
var averageValue = confidenceValues.Average();
|
|
|
|
// Round to nearest confidence level
|
|
var roundedValue = Math.Round(averageValue);
|
|
|
|
// Ensure the value is within valid confidence enum range
|
|
roundedValue = Math.Max(0, Math.Min(3, roundedValue));
|
|
|
|
return (Confidence)(int)roundedValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates context strategies based on confidence levels indicating market condition quality
|
|
/// </summary>
|
|
private static bool ValidateContextStrategies(LightScenario scenario, List<LightSignal> contextSignals,
|
|
IndicatorComboConfig config)
|
|
{
|
|
var contextStrategiesCount = scenario.Indicators.Count(s => s.SignalType == SignalType.Context);
|
|
|
|
if (contextStrategiesCount == 0)
|
|
{
|
|
return true; // No context strategies, no restrictions
|
|
}
|
|
|
|
// Check if we have signals from all context strategies
|
|
if (contextSignals.Count != contextStrategiesCount)
|
|
{
|
|
return false; // Missing context information
|
|
}
|
|
|
|
// All context strategies must meet minimum confidence requirements
|
|
// This ensures market conditions are suitable for trading
|
|
return contextSignals.All(s => s.Confidence >= config.MinimumContextConfidence);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evaluates trend direction using majority voting with neutral handling
|
|
/// </summary>
|
|
private static TradeDirection EvaluateTrendDirection(List<Signal> trendSignals, IndicatorComboConfig config)
|
|
{
|
|
if (!trendSignals.Any())
|
|
{
|
|
return TradeDirection.None; // No trend information available
|
|
}
|
|
|
|
var longCount = trendSignals.Count(s => s.Direction == TradeDirection.Long);
|
|
var shortCount = trendSignals.Count(s => s.Direction == TradeDirection.Short);
|
|
var neutralCount = trendSignals.Count(s => s.Direction == TradeDirection.None);
|
|
|
|
// Strong trend agreement using configurable threshold
|
|
var totalTrend = trendSignals.Count;
|
|
if (longCount > totalTrend * config.TrendStrongAgreementThreshold)
|
|
return TradeDirection.Long;
|
|
if (shortCount > totalTrend * config.TrendStrongAgreementThreshold)
|
|
return TradeDirection.Short;
|
|
|
|
// Moderate trend agreement (> 50% but <= strong threshold)
|
|
if (longCount > shortCount && longCount > neutralCount)
|
|
return TradeDirection.Long;
|
|
if (shortCount > longCount && shortCount > neutralCount)
|
|
return TradeDirection.Short;
|
|
|
|
// No clear trend or too many neutrals
|
|
return TradeDirection.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evaluates signal direction using weighted majority voting
|
|
/// </summary>
|
|
private static TradeDirection EvaluateSignalDirection(List<Signal> signalStrategies, IndicatorComboConfig config)
|
|
{
|
|
if (!signalStrategies.Any())
|
|
{
|
|
return TradeDirection.None; // No signal strategies
|
|
}
|
|
|
|
// For signal strategies, we need stronger agreement since they're rare and should be precise
|
|
var longCount = signalStrategies.Count(s => s.Direction == TradeDirection.Long);
|
|
var shortCount = signalStrategies.Count(s => s.Direction == TradeDirection.Short);
|
|
|
|
// Use configurable agreement threshold for signals
|
|
var totalSignals = signalStrategies.Count;
|
|
if (longCount > totalSignals * config.SignalAgreementThreshold)
|
|
return TradeDirection.Long;
|
|
if (shortCount > totalSignals * config.SignalAgreementThreshold)
|
|
return TradeDirection.Short;
|
|
|
|
return TradeDirection.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines final signal direction and confidence based on signal and trend analysis
|
|
/// </summary>
|
|
private static (TradeDirection Direction, Confidence Confidence) DetermineFinalSignal(
|
|
TradeDirection signalDirection,
|
|
TradeDirection trendDirection,
|
|
List<Signal> signalStrategies,
|
|
List<Signal> trendStrategies,
|
|
IndicatorComboConfig config)
|
|
{
|
|
// Priority 1: If we have signal strategies, they take precedence
|
|
if (signalDirection != TradeDirection.None)
|
|
{
|
|
// Signal strategies have fired - check if trend supports or conflicts
|
|
if (trendDirection == signalDirection)
|
|
{
|
|
// Perfect alignment - signal and trend agree
|
|
return (signalDirection, Confidence.High);
|
|
}
|
|
else if (trendDirection == TradeDirection.None)
|
|
{
|
|
// No trend information or neutral trend - medium confidence
|
|
return (signalDirection, Confidence.Medium);
|
|
}
|
|
else if (config.AllowSignalTrendOverride)
|
|
{
|
|
// Trend conflicts with signal but we allow override
|
|
// This could be a trend reversal signal
|
|
return (signalDirection, Confidence.Low);
|
|
}
|
|
else
|
|
{
|
|
// Trend conflicts and we don't allow override
|
|
return (TradeDirection.None, Confidence.None);
|
|
}
|
|
}
|
|
|
|
// Priority 2: Only trend strategies available
|
|
if (trendDirection != TradeDirection.None)
|
|
{
|
|
// Calculate confidence based on trend strength
|
|
var totalTrend = trendStrategies.Count;
|
|
var majorityDirection = trendDirection == TradeDirection.Long
|
|
? trendStrategies.Count(s => s.Direction == TradeDirection.Long)
|
|
: trendStrategies.Count(s => s.Direction == TradeDirection.Short);
|
|
|
|
var agreementPercentage = (decimal)majorityDirection / totalTrend;
|
|
|
|
if (agreementPercentage >= 0.8m)
|
|
return (trendDirection, Confidence.High);
|
|
else if (agreementPercentage >= config.TrendStrongAgreementThreshold)
|
|
return (trendDirection, Confidence.Medium);
|
|
else
|
|
return (trendDirection, Confidence.Low);
|
|
}
|
|
|
|
// No valid signal found
|
|
return (TradeDirection.None, Confidence.None);
|
|
}
|
|
|
|
public static MoneyManagement GetBestMoneyManagement(List<Candle> candles, List<Position> positions,
|
|
MoneyManagement originMoneyManagement)
|
|
{
|
|
// Foreach positions, identitify the price when the position is open
|
|
// Then, foreach candles, get the maximum price before the next position
|
|
// Then, identify the lowest price before the maximum price
|
|
// Base on that, return the best StopLoss and TakeProfit to use and build a
|
|
var moneyManagement = new MoneyManagement();
|
|
var stoplossPercentage = new List<decimal>();
|
|
var takeProfitsPercentage = new List<decimal>();
|
|
|
|
if (positions.Count == 0)
|
|
return null;
|
|
|
|
for (var i = 0; i < positions.Count; i++)
|
|
{
|
|
var position = positions[i];
|
|
var nextPosition = i + 1 < positions.Count ? positions[i + 1] : null;
|
|
var (stopLoss, takeProfit) = GetBestSltpForPosition(candles, position, nextPosition);
|
|
|
|
stoplossPercentage.Add(stopLoss);
|
|
takeProfitsPercentage.Add(takeProfit);
|
|
}
|
|
|
|
moneyManagement.StopLoss = stoplossPercentage.Average();
|
|
moneyManagement.TakeProfit = takeProfitsPercentage.Average();
|
|
moneyManagement.Timeframe = originMoneyManagement.Timeframe;
|
|
moneyManagement.Leverage = originMoneyManagement.Leverage;
|
|
moneyManagement.Name = "Optimized";
|
|
return moneyManagement;
|
|
}
|
|
|
|
public static (decimal Stoploss, decimal TakeProfit) GetBestSltpForPosition(List<Candle> candles, Position position,
|
|
Position nextPosition)
|
|
{
|
|
var stopLoss = 0M;
|
|
var takeProfit = 0M;
|
|
var candlesBeforeNextPosition = candles.Where(c =>
|
|
c.Date >= position.Date && c.Date <= (nextPosition == null ? candles.Last().Date : nextPosition.Date))
|
|
.ToList();
|
|
|
|
if (position.OriginDirection == TradeDirection.Long)
|
|
{
|
|
var maxPrice = candlesBeforeNextPosition.Max(c => c.High);
|
|
var minPrice = candlesBeforeNextPosition.TakeWhile(c => c.High <= maxPrice).Min(c => c.Low);
|
|
stopLoss = GetPercentageFromEntry(position.Open.Price, minPrice);
|
|
takeProfit = GetPercentageFromEntry(position.Open.Price, maxPrice);
|
|
}
|
|
else if (position.OriginDirection == TradeDirection.Short)
|
|
{
|
|
var minPrice = candlesBeforeNextPosition.Min(c => c.Low);
|
|
var maxPrice = candlesBeforeNextPosition.TakeWhile(c => c.Low >= minPrice).Max(c => c.High);
|
|
stopLoss = GetPercentageFromEntry(position.Open.Price, maxPrice);
|
|
takeProfit = GetPercentageFromEntry(position.Open.Price, minPrice);
|
|
}
|
|
|
|
return (stopLoss, takeProfit);
|
|
}
|
|
|
|
private static decimal GetPercentageFromEntry(decimal entry, decimal price)
|
|
{
|
|
return Math.Abs(100 - ((100 * price) / entry));
|
|
}
|
|
|
|
public static ProfitAndLoss GetProfitAndLoss(Position position, decimal quantity, decimal price, decimal leverage)
|
|
{
|
|
var orders = new List<Tuple<decimal, decimal>>
|
|
{
|
|
new Tuple<decimal, decimal>(position.Open.Quantity, position.Open.Price),
|
|
new Tuple<decimal, decimal>(-quantity, price)
|
|
};
|
|
|
|
|
|
var pnl = new ProfitAndLoss(orders, position.OriginDirection);
|
|
// Apply leverage on the realized pnl
|
|
pnl.Realized = pnl.Realized * leverage;
|
|
return pnl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total volume traded across all positions
|
|
/// Only includes valid positions (Filled, Finished, Flipped) - excludes New, Canceled, Rejected
|
|
/// </summary>
|
|
/// <param name="positions">List of positions to analyze</param>
|
|
/// <returns>The total volume traded in decimal</returns>
|
|
public static decimal GetTotalVolumeTraded(List<Position> positions)
|
|
{
|
|
decimal totalVolume = 0;
|
|
|
|
foreach (var position in positions)
|
|
{
|
|
// Only count volume for valid positions (Filled, Finished, Flipped)
|
|
if (!position.IsValidForMetrics())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Add entry volume
|
|
totalVolume += position.Open.Quantity * position.Open.Price * position.Open.Leverage;
|
|
|
|
// Add exit volumes from stop loss or take profits if they were executed
|
|
if (position.StopLoss.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.StopLoss.Quantity * position.StopLoss.Price * position.StopLoss.Leverage;
|
|
}
|
|
|
|
if (position.TakeProfit1.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.TakeProfit1.Quantity * position.TakeProfit1.Price *
|
|
position.TakeProfit1.Leverage;
|
|
}
|
|
|
|
if (position.TakeProfit2 != null && position.TakeProfit2.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.TakeProfit2.Quantity * position.TakeProfit2.Price *
|
|
position.TakeProfit2.Leverage;
|
|
}
|
|
}
|
|
|
|
return totalVolume;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the volume traded in the last 24 hours
|
|
/// </summary>
|
|
/// <param name="positions">List of positions to analyze</param>
|
|
/// <returns>The volume traded in the last 24 hours in decimal</returns>
|
|
public static decimal GetLast24HVolumeTraded(Dictionary<Guid, Position> positions)
|
|
{
|
|
decimal last24hVolume = 0;
|
|
DateTime cutoff = DateTime.UtcNow.AddHours(-24);
|
|
|
|
foreach (var position in positions.Values)
|
|
{
|
|
// Check if any part of this position was traded in the last 24 hours
|
|
|
|
// Add entry volume if it was within the last 24 hours
|
|
if (position.Open.Date >= cutoff)
|
|
{
|
|
last24hVolume += position.Open.Quantity * position.Open.Price;
|
|
}
|
|
|
|
// Add exit volumes if they were executed within the last 24 hours
|
|
if (position.StopLoss.Status == TradeStatus.Filled && position.StopLoss.Date >= cutoff)
|
|
{
|
|
last24hVolume += position.StopLoss.Quantity * position.StopLoss.Price;
|
|
}
|
|
|
|
if (position.TakeProfit1.Status == TradeStatus.Filled && position.TakeProfit1.Date >= cutoff)
|
|
{
|
|
last24hVolume += position.TakeProfit1.Quantity * position.TakeProfit1.Price;
|
|
}
|
|
|
|
if (position.TakeProfit2 != null && position.TakeProfit2.Status == TradeStatus.Filled &&
|
|
position.TakeProfit2.Date >= cutoff)
|
|
{
|
|
last24hVolume += position.TakeProfit2.Quantity * position.TakeProfit2.Price;
|
|
}
|
|
}
|
|
|
|
return last24hVolume;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the win/loss counts from positions
|
|
/// Counts all positions including open ones based on their current PnL
|
|
/// </summary>
|
|
/// <param name="positions">List of positions to analyze</param>
|
|
/// <returns>A tuple containing (wins, losses)</returns>
|
|
public static (int Wins, int Losses) GetWinLossCount(List<Position> positions)
|
|
{
|
|
int wins = 0;
|
|
int losses = 0;
|
|
|
|
foreach (var position in positions)
|
|
{
|
|
if (position.ProfitAndLoss != null && position.ProfitAndLoss.Realized > 0)
|
|
{
|
|
wins++;
|
|
}
|
|
else if (position.ProfitAndLoss != null && position.ProfitAndLoss.Realized <= 0)
|
|
{
|
|
losses++;
|
|
}
|
|
}
|
|
|
|
return (wins, losses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total realized profit and loss (before fees) for all valid positions.
|
|
/// This represents the gross PnL from trading activities.
|
|
/// </summary>
|
|
/// <param name="positions">Dictionary of positions to analyze</param>
|
|
/// <returns>Returns the total realized PnL before fees as a decimal value.</returns>
|
|
public static decimal GetTotalRealizedPnL(Dictionary<Guid, Position> positions)
|
|
{
|
|
decimal realizedPnl = 0;
|
|
|
|
foreach (var position in positions.Values)
|
|
{
|
|
if (position.IsValidForMetrics() && position.ProfitAndLoss != null)
|
|
{
|
|
realizedPnl += position.ProfitAndLoss.Realized;
|
|
}
|
|
}
|
|
|
|
return realizedPnl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total net profit and loss (after fees) for all valid positions.
|
|
/// This represents the actual profit after accounting for all trading costs.
|
|
/// </summary>
|
|
/// <param name="positions">Dictionary of positions to analyze</param>
|
|
/// <returns>Returns the total net PnL after fees as a decimal value.</returns>
|
|
public static decimal GetTotalNetPnL(Dictionary<Guid, Position> positions)
|
|
{
|
|
decimal netPnl = 0;
|
|
|
|
foreach (var position in positions.Values)
|
|
{
|
|
if (position.IsValidForMetrics() && position.ProfitAndLoss != null)
|
|
{
|
|
netPnl += position.ProfitAndLoss.Net;
|
|
}
|
|
}
|
|
|
|
return netPnl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the win rate percentage for all valid positions.
|
|
/// Win rate is the percentage of positions that are in profit.
|
|
/// </summary>
|
|
/// <param name="positions">Dictionary of positions to analyze</param>
|
|
/// <returns>Returns the win rate as a percentage (0-100)</returns>
|
|
public static int GetWinRate(Dictionary<Guid, Position> positions)
|
|
{
|
|
// Win rate only considers closed positions (Finished status)
|
|
// Open positions have unrealized P&L and shouldn't count toward win rate
|
|
int succeededPositions = 0;
|
|
int totalPositions = 0;
|
|
|
|
foreach (var position in positions.Values)
|
|
{
|
|
if (position.Status == PositionStatus.Finished)
|
|
{
|
|
totalPositions++;
|
|
if (position.IsInProfit())
|
|
{
|
|
succeededPositions++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalPositions == 0)
|
|
return 0;
|
|
|
|
return (succeededPositions * 100) / totalPositions;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total fees paid for all valid positions.
|
|
/// Includes UI fees (0.1% of position size) and network fees ($0.15 for opening).
|
|
/// Closing fees are handled by oracle, so no network fee for closing.
|
|
/// </summary>
|
|
/// <param name="positions">Dictionary of positions to analyze</param>
|
|
/// <returns>Returns the total fees paid as a decimal value.</returns>
|
|
public static decimal GetTotalFees(Dictionary<Guid, Position> positions)
|
|
{
|
|
// Optimized: Avoid LINQ Where overhead, inline the check
|
|
decimal totalFees = 0;
|
|
|
|
foreach (var position in positions.Values)
|
|
{
|
|
if (position.IsValidForMetrics())
|
|
{
|
|
totalFees += CalculatePositionFees(position);
|
|
}
|
|
}
|
|
|
|
return totalFees;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates indicators values for a given scenario and candles.
|
|
/// </summary>
|
|
/// <param name="scenario">The scenario containing indicators.</param>
|
|
/// <param name="candles">The candles to calculate indicators for.</param>
|
|
/// <returns>A dictionary of indicator types to their calculated values.</returns>
|
|
public static Dictionary<IndicatorType, IndicatorsResultBase> CalculateIndicatorsValues(
|
|
Scenario scenario,
|
|
HashSet<Candle> candles)
|
|
{
|
|
var indicatorsValues = new Dictionary<IndicatorType, IndicatorsResultBase>();
|
|
|
|
if (scenario?.Indicators == null || scenario.Indicators.Count == 0)
|
|
{
|
|
return indicatorsValues;
|
|
}
|
|
|
|
// Build indicators from scenario
|
|
foreach (var indicator in scenario.Indicators)
|
|
{
|
|
try
|
|
{
|
|
var buildedIndicator = ScenarioHelpers.BuildIndicator(ScenarioHelpers.BaseToLight(indicator));
|
|
indicatorsValues[indicator.Type] = buildedIndicator.GetIndicatorValues(candles);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Removed logging for performance in static method
|
|
// Consider adding logging back if error handling is needed
|
|
}
|
|
}
|
|
|
|
return indicatorsValues;
|
|
}
|
|
|
|
public static decimal GetHodlPercentage(Candle candle1, Candle candle2)
|
|
{
|
|
return candle2.Close * 100 / candle1.Close - 100;
|
|
}
|
|
|
|
public static decimal GetGrowthFromInitalBalance(decimal balance, decimal finalPnl)
|
|
{
|
|
var growth = balance + finalPnl;
|
|
|
|
return growth * 100 / balance - 100;
|
|
}
|
|
|
|
public static PerformanceMetrics GetStatistics(Dictionary<DateTime, decimal> pnls)
|
|
{
|
|
var priceSeries = new TimePriceSeries(pnls.DistinctBy(p => p.Key).ToDictionary(p => p.Key, p => p.Value));
|
|
|
|
return priceSeries.CalculatePerformanceMetrics();
|
|
}
|
|
|
|
public static decimal GetFeeAmount(decimal fee, decimal amount)
|
|
{
|
|
return fee * amount;
|
|
}
|
|
|
|
public static decimal GetFeeAmount(decimal fee, decimal amount, TradingExchanges exchange)
|
|
{
|
|
if (exchange.Equals(TradingExchanges.Evm))
|
|
return fee;
|
|
|
|
return GetFeeAmount(fee, amount);
|
|
}
|
|
|
|
public static bool IsAGoodTrader(Trader trader)
|
|
{
|
|
return trader.Winrate > 30
|
|
&& trader.TradeCount > 8
|
|
&& trader.AverageWin > Math.Abs(trader.AverageLoss)
|
|
&& trader.Pnl > 0;
|
|
}
|
|
|
|
public static bool IsABadTrader(Trader trader)
|
|
{
|
|
return trader.Winrate < 30
|
|
&& trader.TradeCount > 8
|
|
&& trader.AverageWin * 3 < Math.Abs(trader.AverageLoss)
|
|
&& trader.Pnl < 0;
|
|
}
|
|
|
|
public static List<Trader> FindBadTrader(this List<Trader> traders)
|
|
{
|
|
var filteredTrader = new List<Trader>();
|
|
foreach (var trader in traders)
|
|
{
|
|
if (IsABadTrader(trader))
|
|
{
|
|
filteredTrader.Add(trader);
|
|
}
|
|
}
|
|
|
|
return filteredTrader;
|
|
}
|
|
|
|
public static List<Trader> FindGoodTrader(this List<Trader> traders)
|
|
{
|
|
var filteredTrader = new List<Trader>();
|
|
foreach (var trader in traders)
|
|
{
|
|
if (IsAGoodTrader(trader))
|
|
{
|
|
filteredTrader.Add(trader);
|
|
}
|
|
}
|
|
|
|
return filteredTrader;
|
|
}
|
|
|
|
public static List<Trader> MapToTraders(this List<Account> accounts)
|
|
{
|
|
var traders = new List<Trader>();
|
|
foreach (var account in accounts)
|
|
{
|
|
traders.Add(new Trader
|
|
{
|
|
Address = account.Key
|
|
});
|
|
}
|
|
|
|
return traders;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total fees for a position based on GMX V2 fee structure
|
|
/// </summary>
|
|
/// <param name="position">The position to calculate fees for</param>
|
|
/// <returns>The total fees for the position</returns>
|
|
public static decimal CalculatePositionFees(Position position)
|
|
{
|
|
var (uiFees, gasFees) = CalculatePositionFeesBreakdown(position);
|
|
return uiFees + gasFees;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the UI and Gas fees breakdown for a position based on GMX V2 fee structure
|
|
/// </summary>
|
|
/// <param name="position">The position to calculate fees for</param>
|
|
/// <returns>A tuple containing (uiFees, gasFees)</returns>
|
|
public static (decimal uiFees, decimal gasFees) CalculatePositionFeesBreakdown(Position position)
|
|
{
|
|
decimal uiFees = 0;
|
|
decimal gasFees = 0;
|
|
|
|
if (position?.Open?.Price <= 0 || position?.Open?.Quantity <= 0)
|
|
{
|
|
return (uiFees, gasFees); // Return 0 if position data is invalid
|
|
}
|
|
|
|
// Calculate position size in USD (leverage is already included in quantity calculation)
|
|
var positionSizeUsd = (position.Open.Price * position.Open.Quantity) * position.Open.Leverage;
|
|
|
|
// UI Fee: 0.1% of position size paid on opening
|
|
var uiFeeOpen = positionSizeUsd * Constants.GMX.Config.UiFeeRate; // Fee paid on opening
|
|
uiFees += uiFeeOpen;
|
|
|
|
// UI Fee: 0.1% of position size paid on closing - only if position was actually closed
|
|
// Check which closing trade was executed (StopLoss, TakeProfit1, or TakeProfit2)
|
|
if (position.StopLoss?.Status == TradeStatus.Filled)
|
|
{
|
|
var stopLossPositionSizeUsd =
|
|
(position.StopLoss.Price * position.StopLoss.Quantity) * position.StopLoss.Leverage;
|
|
var uiFeeClose =
|
|
stopLossPositionSizeUsd * Constants.GMX.Config.UiFeeRate; // Fee paid on closing via StopLoss
|
|
uiFees += uiFeeClose;
|
|
}
|
|
else if (position.TakeProfit1?.Status == TradeStatus.Filled)
|
|
{
|
|
var takeProfit1PositionSizeUsd = (position.TakeProfit1.Price * position.TakeProfit1.Quantity) *
|
|
position.TakeProfit1.Leverage;
|
|
var uiFeeClose =
|
|
takeProfit1PositionSizeUsd * Constants.GMX.Config.UiFeeRate; // Fee paid on closing via TakeProfit1
|
|
uiFees += uiFeeClose;
|
|
}
|
|
else if (position.TakeProfit2?.Status == TradeStatus.Filled)
|
|
{
|
|
var takeProfit2PositionSizeUsd = (position.TakeProfit2.Price * position.TakeProfit2.Quantity) *
|
|
position.TakeProfit2.Leverage;
|
|
var uiFeeClose =
|
|
takeProfit2PositionSizeUsd * Constants.GMX.Config.UiFeeRate; // Fee paid on closing via TakeProfit2
|
|
uiFees += uiFeeClose;
|
|
}
|
|
|
|
// Gas Fee: $0.15 for opening position only
|
|
// Closing is handled by oracle, so no gas fee for closing
|
|
gasFees += Constants.GMX.Config.GasFeePerTransaction;
|
|
|
|
return (uiFees, gasFees);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates UI fees for opening a position
|
|
/// </summary>
|
|
/// <param name="positionSizeUsd">The position size in USD</param>
|
|
/// <returns>The UI fees for opening</returns>
|
|
public static decimal CalculateOpeningUiFees(decimal positionSizeUsd)
|
|
{
|
|
return positionSizeUsd * Constants.GMX.Config.UiFeeRate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates UI fees for closing a position
|
|
/// </summary>
|
|
/// <param name="positionSizeUsd">The position size in USD</param>
|
|
/// <returns>The UI fees for closing</returns>
|
|
public static decimal CalculateClosingUiFees(decimal positionSizeUsd)
|
|
{
|
|
return positionSizeUsd * Constants.GMX.Config.UiFeeRate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates gas fees for opening a position
|
|
/// </summary>
|
|
/// <returns>The gas fees for opening (fixed at $0.15)</returns>
|
|
public static decimal CalculateOpeningGasFees()
|
|
{
|
|
return Constants.GMX.Config.GasFeePerTransaction;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the total volume for a position based on its status and filled trades
|
|
/// </summary>
|
|
/// <param name="position">The position to calculate volume for</param>
|
|
/// <returns>The total volume for the position</returns>
|
|
public static decimal GetVolumeForPosition(Position position)
|
|
{
|
|
// Always include the opening trade volume
|
|
var totalVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage;
|
|
|
|
// For closed positions, add volume from filled closing trades
|
|
if (position.IsValidForMetrics())
|
|
{
|
|
// Add Stop Loss volume if filled
|
|
if (position.StopLoss?.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.StopLoss.Price * position.StopLoss.Quantity * position.StopLoss.Leverage;
|
|
}
|
|
|
|
// Add Take Profit 1 volume if filled
|
|
if (position.TakeProfit1?.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.TakeProfit1.Price * position.TakeProfit1.Quantity *
|
|
position.TakeProfit1.Leverage;
|
|
}
|
|
|
|
// Add Take Profit 2 volume if filled
|
|
if (position.TakeProfit2?.Status == TradeStatus.Filled)
|
|
{
|
|
totalVolume += position.TakeProfit2.Price * position.TakeProfit2.Quantity *
|
|
position.TakeProfit2.Leverage;
|
|
}
|
|
}
|
|
|
|
return totalVolume;
|
|
}
|
|
} |