Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions csharp/TraderBot/KellyCriterion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace TraderBot;

public static class KellyCriterion
{
/// <summary>
/// Calculates the optimal bet size fraction using the Kelly Criterion formula.
/// Formula: f = (bp - q) / b
/// Where:
/// - f = fraction of capital to bet
/// - b = profit/loss ratio (odds)
/// - p = probability of winning
/// - q = probability of losing (1-p)
/// </summary>
/// <param name="winProbability">Probability of winning (0.0 to 1.0)</param>
/// <param name="profitLossRatio">The ratio of profit to loss (e.g., 2.0 means profit is 2x the loss)</param>
/// <param name="maxFraction">Maximum fraction to limit risk (default 0.25)</param>
/// <returns>The optimal fraction of capital to bet (0.0 to maxFraction)</returns>
public static double CalculateOptimalBetSize(double winProbability, double profitLossRatio, double maxFraction = 0.25)
{
if (winProbability < 0 || winProbability > 1)
throw new ArgumentException("Win probability must be between 0 and 1", nameof(winProbability));

if (profitLossRatio <= 0)
throw new ArgumentException("Profit/loss ratio must be positive", nameof(profitLossRatio));

if (maxFraction <= 0 || maxFraction > 1)
throw new ArgumentException("Max fraction must be between 0 and 1", nameof(maxFraction));

double lossProbability = 1.0 - winProbability;

// Kelly Criterion formula: f = (bp - q) / b
double kellyFraction = (profitLossRatio * winProbability - lossProbability) / profitLossRatio;

// Return 0 if Kelly suggests negative betting (negative expected value)
if (kellyFraction <= 0)
return 0.0;

// Cap at maximum fraction to limit risk
return Math.Min(kellyFraction, maxFraction);
}

/// <summary>
/// Calculates the win probability and profit/loss ratio from historical operations
/// </summary>
/// <param name="operations">List of completed operations</param>
/// <returns>Tuple containing (winProbability, profitLossRatio)</returns>
public static (double WinProbability, double ProfitLossRatio) CalculateHistoricalMetrics(
IEnumerable<(DateTime Date, decimal BuyPrice, decimal SellPrice)> operations)
{
var operationsList = operations.ToList();
if (operationsList.Count < 10) // Need minimum historical data
return (0.5, 1.0); // Default conservative values

var wins = 0;
var totalProfit = 0.0m;
var totalLoss = 0.0m;

foreach (var op in operationsList)
{
var profit = op.SellPrice - op.BuyPrice;
if (profit > 0)
{
wins++;
totalProfit += profit;
}
else if (profit < 0)
{
totalLoss += Math.Abs(profit);
}
}

var winProbability = (double)wins / operationsList.Count;
var avgProfit = wins > 0 ? (double)(totalProfit / wins) : 0.0;
var avgLoss = (operationsList.Count - wins) > 0 ? (double)(totalLoss / (operationsList.Count - wins)) : 1.0;
var profitLossRatio = avgLoss > 0 ? avgProfit / avgLoss : 1.0;

return (winProbability, Math.Max(profitLossRatio, 0.1)); // Minimum ratio to avoid division issues
}
}
67 changes: 64 additions & 3 deletions csharp/TraderBot/TradingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public class TradingService : BackgroundService
protected readonly ConcurrentDictionary<string, OrderState> ActiveSellOrders;
protected readonly ConcurrentDictionary<decimal, long> LotsSets;
protected readonly ConcurrentDictionary<string, decimal> ActiveSellOrderSourcePrice;
protected readonly List<(DateTime Date, decimal BuyPrice, decimal SellPrice)> CompletedOperations;

public TradingService(ILogger<TradingService> logger, InvestApiClient investApi, IHostApplicationLifetime lifetime, TradingSettings settings)
{
Expand Down Expand Up @@ -111,6 +112,7 @@ public TradingService(ILogger<TradingService> logger, InvestApiClient investApi,
ActiveSellOrders = new ConcurrentDictionary<string, OrderState>();
LotsSets = new ConcurrentDictionary<decimal, long>();
ActiveSellOrderSourcePrice = new ConcurrentDictionary<string, decimal>();
CompletedOperations = new List<(DateTime, decimal, decimal)>();
LastOperationsCheckpoint = settings.LoadOperationsFrom;
}

Expand Down Expand Up @@ -290,7 +292,15 @@ protected void TrySubtractTradesFromOrder(ConcurrentDictionary<string, OrderStat
if (activeOrder.LotsRequested == 0)
{
orders.TryRemove(orderTrades.OrderId, out activeOrder);
ActiveSellOrderSourcePrice.TryRemove(orderTrades.OrderId, out decimal sourcePrice);

// Track completed buy-sell cycle for Kelly Criterion
if (orders == ActiveSellOrders && ActiveSellOrderSourcePrice.TryGetValue(orderTrades.OrderId, out decimal sourcePrice))
{
var sellPrice = MoneyValueToDecimal(activeOrder.InitialSecurityPrice);
TrackCompletedOperation(sourcePrice, sellPrice);
}

ActiveSellOrderSourcePrice.TryRemove(orderTrades.OrderId, out decimal _);
Logger.LogInformation($"Active order removed: {activeOrder}");
}
}
Expand Down Expand Up @@ -477,7 +487,7 @@ await marketDataStream.RequestStream.WriteAsync(new MarketDataRequest
{
Logger.LogInformation($"buy activated");
Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}.");
var lots = (long)(cashBalance / lotPrice);
var lots = CalculateOptimalLotSize(cashBalance, lotPrice);
var marketLotsAtTargetPrice = orderBook.Bids.FirstOrDefault(o => o.Price == bestBid)?.Quantity ?? 0;
Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}");
var response = await PlaceBuyOrder(lots, bestBid);
Expand Down Expand Up @@ -544,7 +554,7 @@ await marketDataStream.RequestStream.WriteAsync(new MarketDataRequest
var lotPrice = bestBid * LotSize;
if (cashBalance > lotPrice)
{
var lots = (long)(cashBalance / lotPrice);
var lots = CalculateOptimalLotSize(cashBalance, lotPrice);
var marketLotsAtTargetPrice = orderBook.Bids.FirstOrDefault(o => o.Price == bestBid)?.Quantity ?? 0;
Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}");
var response = await PlaceBuyOrder(lots, bestBid);
Expand Down Expand Up @@ -652,6 +662,57 @@ private bool IsTimeToBuy()
{
var currentTime = DateTime.UtcNow.TimeOfDay;
return currentTime > MinimumTimeToBuy && currentTime < MaximumTimeToBuy;
}

private long CalculateOptimalLotSize(decimal cashBalance, decimal lotPrice)
{
if (!Settings.UseKellyCriterion)
{
// Use traditional sizing: all available cash
return (long)(cashBalance / lotPrice);
}

double winProbability = Settings.WinProbability;
double profitLossRatio = Settings.ProfitLossRatio;

// If we have enough historical data, calculate metrics dynamically
if (CompletedOperations.Count >= 10)
{
var (historicalWinProb, historicalRatio) = KellyCriterion.CalculateHistoricalMetrics(CompletedOperations);
winProbability = historicalWinProb;
profitLossRatio = historicalRatio;
Logger.LogInformation($"Using historical metrics - Win Probability: {winProbability:F3}, Profit/Loss Ratio: {profitLossRatio:F3}");
}
else
{
Logger.LogInformation($"Using configured metrics - Win Probability: {winProbability:F3}, Profit/Loss Ratio: {profitLossRatio:F3}");
}

var kellyFraction = KellyCriterion.CalculateOptimalBetSize(winProbability, profitLossRatio, Settings.KellyFractionLimit);
var optimalCashToUse = cashBalance * (decimal)kellyFraction;
var lots = (long)Math.Max(1, optimalCashToUse / lotPrice); // Ensure at least 1 lot

Logger.LogInformation($"Kelly Criterion: Fraction={kellyFraction:F3}, OptimalCash={optimalCashToUse:F2}, Lots={lots}");

return lots;
}

private void TrackCompletedOperation(decimal buyPrice, decimal sellPrice)
{
lock (CompletedOperations)
{
CompletedOperations.Add((DateTime.UtcNow, buyPrice, sellPrice));

// Keep only last 100 operations to prevent memory growth
if (CompletedOperations.Count > 100)
{
CompletedOperations.RemoveAt(0);
}
}

var profit = sellPrice - buyPrice;
var profitPercent = (profit / buyPrice) * 100;
Logger.LogInformation($"Operation completed: Buy={buyPrice}, Sell={sellPrice}, Profit={profit:F4} ({profitPercent:F2}%)");
}

private async Task<(decimal, decimal)> GetCashBalance(bool forceRemote = false)
Expand Down
4 changes: 4 additions & 0 deletions csharp/TraderBot/TradingSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ public class TradingSettings
public long EarlySellOwnedLotsDelta { get; set; }
public decimal EarlySellOwnedLotsMultiplier { get; set; }
public DateTime LoadOperationsFrom { get; set; }
public bool UseKellyCriterion { get; set; }
public double WinProbability { get; set; }
public double ProfitLossRatio { get; set; }
public double KellyFractionLimit { get; set; } = 0.25;
}
6 changes: 5 additions & 1 deletion csharp/TraderBot/appsettings.TMON.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"MaximumTimeToBuy": "23:59:59",
"EarlySellOwnedLotsDelta": 300000,
"EarlySellOwnedLotsMultiplier": 0,
"LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z"
"LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z",
"UseKellyCriterion": true,
"WinProbability": 0.55,
"ProfitLossRatio": 1.2,
"KellyFractionLimit": 0.25
}
}
6 changes: 5 additions & 1 deletion csharp/TraderBot/appsettings.TRUR.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"MaximumTimeToBuy": "14:45:00",
"EarlySellOwnedLotsDelta": 300000,
"EarlySellOwnedLotsMultiplier": 0,
"LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z"
"LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z",
"UseKellyCriterion": true,
"WinProbability": 0.52,
"ProfitLossRatio": 1.1,
"KellyFractionLimit": 0.2
}
}
Loading
Loading