Skip to content

Commit ae15684

Browse files
konardclaude
andcommitted
Implement automatic profit calculation with absolute and relative values
- Add ProfitCalculationService with comprehensive profit analysis - Calculate realized and unrealized profits using FIFO method - Display absolute profit amounts and relative percentages - Integrate with existing TradingService to show profit metrics - Add comprehensive unit tests covering various scenarios - Support both absolute (monetary) and relative (percentage) profit values - Log detailed profit analysis including completed trades and open positions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent b89df02 commit ae15684

File tree

5 files changed

+428
-1
lines changed

5 files changed

+428
-1
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using Microsoft.Extensions.Logging;
2+
using Moq;
3+
using Tinkoff.InvestApi.V1;
4+
using Xunit;
5+
6+
namespace TraderBot.Tests;
7+
8+
using OperationsList = List<(OperationType Type, DateTime Date, long Quantity, decimal Price)>;
9+
10+
public class ProfitCalculationServiceTests
11+
{
12+
private readonly ProfitCalculationService _service;
13+
private readonly Mock<ILogger<ProfitCalculationService>> _mockLogger;
14+
15+
public ProfitCalculationServiceTests()
16+
{
17+
_mockLogger = new Mock<ILogger<ProfitCalculationService>>();
18+
_service = new ProfitCalculationService(_mockLogger.Object);
19+
}
20+
21+
[Fact]
22+
public void CalculateProfit_EmptyOperations_ReturnsZeroProfit()
23+
{
24+
// Arrange
25+
var operations = new OperationsList();
26+
27+
// Act
28+
var result = _service.CalculateProfit(operations);
29+
30+
// Assert
31+
Assert.Equal(0, result.TotalAbsoluteProfit);
32+
Assert.Equal(0, result.TotalRelativeProfit);
33+
Assert.Equal(0, result.RealizedAbsoluteProfit);
34+
Assert.Equal(0, result.RealizedRelativeProfit);
35+
Assert.Equal(0, result.UnrealizedAbsoluteProfit);
36+
Assert.Equal(0, result.UnrealizedRelativeProfit);
37+
Assert.Equal(0, result.OpenPositionQuantity);
38+
Assert.Empty(result.CompletedTrades);
39+
}
40+
41+
[Fact]
42+
public void CalculateProfit_OnlyBuyOperations_ShowsUnrealizedProfit()
43+
{
44+
// Arrange
45+
var operations = new OperationsList
46+
{
47+
(OperationType.Buy, DateTime.Now.AddHours(-2), 10, 100.0m),
48+
(OperationType.Buy, DateTime.Now.AddHours(-1), 5, 110.0m)
49+
};
50+
var currentPrice = 120.0m;
51+
52+
// Act
53+
var result = _service.CalculateProfit(operations, currentPrice);
54+
55+
// Assert
56+
Assert.Equal(15, result.OpenPositionQuantity);
57+
Assert.Equal(1550.0m, result.TotalInvested); // (10*100) + (5*110)
58+
Assert.Equal(103.33m, Math.Round(result.AverageBuyPrice, 2)); // 1550/15
59+
Assert.Equal(1800.0m - 1550.0m, result.UnrealizedAbsoluteProfit); // (15*120) - 1550
60+
Assert.Equal(250.0m, result.UnrealizedAbsoluteProfit);
61+
Assert.Equal(Math.Round((250.0m / 1550.0m) * 100, 2), Math.Round(result.UnrealizedRelativeProfit, 2)); // ~16.13%
62+
Assert.Equal(0, result.RealizedAbsoluteProfit);
63+
Assert.Empty(result.CompletedTrades);
64+
}
65+
66+
[Fact]
67+
public void CalculateProfit_BuyAndSellOperations_ShowsRealizedProfit()
68+
{
69+
// Arrange
70+
var operations = new OperationsList
71+
{
72+
(OperationType.Buy, DateTime.Now.AddHours(-3), 10, 100.0m),
73+
(OperationType.Buy, DateTime.Now.AddHours(-2), 5, 110.0m),
74+
(OperationType.Sell, DateTime.Now.AddHours(-1), 8, 120.0m)
75+
};
76+
var currentPrice = 115.0m;
77+
78+
// Act
79+
var result = _service.CalculateProfit(operations, currentPrice);
80+
81+
// Assert
82+
// Realized profit: First 8 lots sold at 120, bought at 100 (FIFO)
83+
// 8 * (120 - 100) = 160
84+
Assert.Equal(160.0m, result.RealizedAbsoluteProfit);
85+
86+
// Remaining position: 2 lots at 100 + 5 lots at 110 = 7 lots
87+
Assert.Equal(7, result.OpenPositionQuantity);
88+
Assert.Equal(750.0m, result.TotalInvested); // (2*100) + (5*110)
89+
90+
// Unrealized profit: 7 lots at current price 115 vs invested 750
91+
var currentValue = 7 * 115.0m; // 805
92+
var unrealizedProfit = currentValue - 750.0m; // 55
93+
Assert.Equal(55.0m, result.UnrealizedAbsoluteProfit);
94+
95+
// Total profit = realized + unrealized
96+
Assert.Equal(215.0m, result.TotalAbsoluteProfit); // 160 + 55
97+
98+
// Should have one completed trade
99+
Assert.Single(result.CompletedTrades);
100+
Assert.Equal(8, result.CompletedTrades[0].Quantity);
101+
Assert.Equal(100.0m, result.CompletedTrades[0].BuyPrice);
102+
Assert.Equal(120.0m, result.CompletedTrades[0].SellPrice);
103+
Assert.Equal(160.0m, result.CompletedTrades[0].AbsoluteProfit);
104+
}
105+
106+
[Fact]
107+
public void CalculateProfit_MultipleBuysAndSells_FIFO_Matching()
108+
{
109+
// Arrange
110+
var operations = new OperationsList
111+
{
112+
(OperationType.Buy, DateTime.Now.AddHours(-6), 10, 100.0m), // Buy 10 @ 100
113+
(OperationType.Buy, DateTime.Now.AddHours(-5), 5, 110.0m), // Buy 5 @ 110
114+
(OperationType.Sell, DateTime.Now.AddHours(-4), 3, 120.0m), // Sell 3 @ 120 (from first buy)
115+
(OperationType.Sell, DateTime.Now.AddHours(-3), 7, 125.0m), // Sell 7 @ 125 (remaining 7 from first buy)
116+
(OperationType.Buy, DateTime.Now.AddHours(-2), 8, 105.0m), // Buy 8 @ 105
117+
(OperationType.Sell, DateTime.Now.AddHours(-1), 2, 130.0m) // Sell 2 @ 130 (from second buy)
118+
};
119+
120+
// Act
121+
var result = _service.CalculateProfit(operations);
122+
123+
// Assert
124+
// Completed trades:
125+
// Trade 1: 3 lots @ 100 -> 120 = 3 * (120-100) = 60
126+
// Trade 2: 7 lots @ 100 -> 125 = 7 * (125-100) = 175
127+
// Trade 3: 2 lots @ 110 -> 130 = 2 * (130-110) = 40
128+
// Total realized: 60 + 175 + 40 = 275
129+
Assert.Equal(275.0m, result.RealizedAbsoluteProfit);
130+
131+
// Remaining positions:
132+
// 3 lots from second buy @ 110
133+
// 8 lots from third buy @ 105
134+
// Total: 11 lots, value: (3*110) + (8*105) = 330 + 840 = 1170
135+
Assert.Equal(11, result.OpenPositionQuantity);
136+
Assert.Equal(1170.0m, result.TotalInvested);
137+
138+
// Should have 3 completed trades
139+
Assert.Equal(3, result.CompletedTrades.Count);
140+
}
141+
142+
[Fact]
143+
public void CalculateProfit_ProfitableTrade_CorrectRelativePercentage()
144+
{
145+
// Arrange
146+
var operations = new OperationsList
147+
{
148+
(OperationType.Buy, DateTime.Now.AddHours(-2), 10, 100.0m), // Invest 1000
149+
(OperationType.Sell, DateTime.Now.AddHours(-1), 10, 110.0m) // Sell for 1100
150+
};
151+
152+
// Act
153+
var result = _service.CalculateProfit(operations);
154+
155+
// Assert
156+
Assert.Equal(100.0m, result.RealizedAbsoluteProfit); // 1100 - 1000
157+
Assert.Equal(10.0m, result.RealizedRelativeProfit); // (100/1000) * 100 = 10%
158+
Assert.Equal(0, result.OpenPositionQuantity); // All positions closed
159+
}
160+
161+
[Fact]
162+
public void CalculateProfit_LossTrade_NegativeProfit()
163+
{
164+
// Arrange
165+
var operations = new OperationsList
166+
{
167+
(OperationType.Buy, DateTime.Now.AddHours(-2), 10, 100.0m), // Invest 1000
168+
(OperationType.Sell, DateTime.Now.AddHours(-1), 10, 90.0m) // Sell for 900
169+
};
170+
171+
// Act
172+
var result = _service.CalculateProfit(operations);
173+
174+
// Assert
175+
Assert.Equal(-100.0m, result.RealizedAbsoluteProfit); // 900 - 1000
176+
Assert.Equal(-10.0m, result.RealizedRelativeProfit); // (-100/1000) * 100 = -10%
177+
}
178+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
using Microsoft.Extensions.Logging;
2+
using Tinkoff.InvestApi.V1;
3+
4+
namespace TraderBot;
5+
6+
using OperationsList = List<(OperationType Type, DateTime Date, long Quantity, decimal Price)>;
7+
8+
public class ProfitCalculationService
9+
{
10+
private readonly ILogger<ProfitCalculationService> Logger;
11+
12+
public ProfitCalculationService(ILogger<ProfitCalculationService> logger)
13+
{
14+
Logger = logger;
15+
}
16+
17+
public ProfitAnalysis CalculateProfit(OperationsList operations, decimal currentPrice = 0)
18+
{
19+
if (operations == null || !operations.Any())
20+
{
21+
return new ProfitAnalysis
22+
{
23+
TotalAbsoluteProfit = 0,
24+
TotalRelativeProfit = 0,
25+
RealizedAbsoluteProfit = 0,
26+
RealizedRelativeProfit = 0,
27+
UnrealizedAbsoluteProfit = 0,
28+
UnrealizedRelativeProfit = 0,
29+
TotalInvested = 0,
30+
OpenPositionQuantity = 0,
31+
AverageBuyPrice = 0,
32+
CompletedTrades = new List<TradeProfit>()
33+
};
34+
}
35+
36+
var analysis = new ProfitAnalysis();
37+
var completedTrades = new List<TradeProfit>();
38+
var openBuyOperations = new List<(OperationType Type, DateTime Date, long Quantity, decimal Price)>();
39+
40+
decimal totalBought = 0;
41+
decimal totalSold = 0;
42+
long totalBoughtQuantity = 0;
43+
long totalSoldQuantity = 0;
44+
45+
// Separate buy and sell operations
46+
var buyOperations = operations.Where(o => o.Type == OperationType.Buy).OrderBy(o => o.Date).ToList();
47+
var sellOperations = operations.Where(o => o.Type == OperationType.Sell).OrderBy(o => o.Date).ToList();
48+
49+
// Calculate completed trades (FIFO matching)
50+
var remainingBuyOps = buyOperations.ToList();
51+
52+
foreach (var sellOp in sellOperations)
53+
{
54+
var remainingSellQuantity = sellOp.Quantity;
55+
56+
while (remainingSellQuantity > 0 && remainingBuyOps.Any())
57+
{
58+
var firstBuy = remainingBuyOps.First();
59+
var matchedQuantity = Math.Min(remainingSellQuantity, firstBuy.Quantity);
60+
61+
// Calculate profit for this matched trade
62+
var buyValue = matchedQuantity * firstBuy.Price;
63+
var sellValue = matchedQuantity * sellOp.Price;
64+
var absoluteProfit = sellValue - buyValue;
65+
var relativeProfit = buyValue > 0 ? (absoluteProfit / buyValue) * 100 : 0;
66+
67+
completedTrades.Add(new TradeProfit
68+
{
69+
BuyDate = firstBuy.Date,
70+
SellDate = sellOp.Date,
71+
Quantity = matchedQuantity,
72+
BuyPrice = firstBuy.Price,
73+
SellPrice = sellOp.Price,
74+
AbsoluteProfit = absoluteProfit,
75+
RelativeProfit = relativeProfit
76+
});
77+
78+
// Update totals
79+
totalSold += sellValue;
80+
totalSoldQuantity += matchedQuantity;
81+
82+
// Update remaining quantities
83+
remainingSellQuantity -= matchedQuantity;
84+
85+
if (firstBuy.Quantity <= matchedQuantity)
86+
{
87+
totalBought += firstBuy.Quantity * firstBuy.Price;
88+
totalBoughtQuantity += firstBuy.Quantity;
89+
remainingBuyOps.RemoveAt(0);
90+
}
91+
else
92+
{
93+
totalBought += matchedQuantity * firstBuy.Price;
94+
totalBoughtQuantity += matchedQuantity;
95+
remainingBuyOps[0] = (firstBuy.Type, firstBuy.Date, firstBuy.Quantity - matchedQuantity, firstBuy.Price);
96+
}
97+
}
98+
}
99+
100+
// Remaining buy operations are open positions
101+
openBuyOperations = remainingBuyOps;
102+
103+
// Calculate realized profit
104+
analysis.RealizedAbsoluteProfit = completedTrades.Sum(t => t.AbsoluteProfit);
105+
analysis.RealizedRelativeProfit = totalBought > 0 ? (analysis.RealizedAbsoluteProfit / totalBought) * 100 : 0;
106+
107+
// Calculate open position metrics
108+
analysis.OpenPositionQuantity = openBuyOperations.Sum(o => o.Quantity);
109+
analysis.TotalInvested = openBuyOperations.Sum(o => o.Quantity * o.Price);
110+
analysis.AverageBuyPrice = analysis.OpenPositionQuantity > 0 ? analysis.TotalInvested / analysis.OpenPositionQuantity : 0;
111+
112+
// Calculate unrealized profit (only if current price is provided)
113+
if (currentPrice > 0 && analysis.OpenPositionQuantity > 0)
114+
{
115+
var currentValue = analysis.OpenPositionQuantity * currentPrice;
116+
analysis.UnrealizedAbsoluteProfit = currentValue - analysis.TotalInvested;
117+
analysis.UnrealizedRelativeProfit = analysis.TotalInvested > 0 ? (analysis.UnrealizedAbsoluteProfit / analysis.TotalInvested) * 100 : 0;
118+
}
119+
120+
// Calculate total profit
121+
analysis.TotalAbsoluteProfit = analysis.RealizedAbsoluteProfit + analysis.UnrealizedAbsoluteProfit;
122+
var totalInvestment = totalBought + analysis.TotalInvested;
123+
analysis.TotalRelativeProfit = totalInvestment > 0 ? (analysis.TotalAbsoluteProfit / totalInvestment) * 100 : 0;
124+
125+
analysis.CompletedTrades = completedTrades;
126+
127+
return analysis;
128+
}
129+
130+
public void LogProfitAnalysis(ProfitAnalysis analysis)
131+
{
132+
Logger.LogInformation("=== PROFIT ANALYSIS ===");
133+
Logger.LogInformation($"Total Absolute Profit: {analysis.TotalAbsoluteProfit:F4}");
134+
Logger.LogInformation($"Total Relative Profit: {analysis.TotalRelativeProfit:F2}%");
135+
Logger.LogInformation("--- Realized Profit ---");
136+
Logger.LogInformation($"Realized Absolute Profit: {analysis.RealizedAbsoluteProfit:F4}");
137+
Logger.LogInformation($"Realized Relative Profit: {analysis.RealizedRelativeProfit:F2}%");
138+
Logger.LogInformation("--- Unrealized Profit ---");
139+
Logger.LogInformation($"Unrealized Absolute Profit: {analysis.UnrealizedAbsoluteProfit:F4}");
140+
Logger.LogInformation($"Unrealized Relative Profit: {analysis.UnrealizedRelativeProfit:F2}%");
141+
Logger.LogInformation("--- Position Info ---");
142+
Logger.LogInformation($"Open Position Quantity: {analysis.OpenPositionQuantity}");
143+
Logger.LogInformation($"Total Invested in Open Positions: {analysis.TotalInvested:F4}");
144+
Logger.LogInformation($"Average Buy Price: {analysis.AverageBuyPrice:F4}");
145+
Logger.LogInformation($"Completed Trades: {analysis.CompletedTrades.Count}");
146+
147+
if (analysis.CompletedTrades.Any())
148+
{
149+
Logger.LogInformation("--- Recent Completed Trades ---");
150+
var recentTrades = analysis.CompletedTrades.TakeLast(5);
151+
foreach (var trade in recentTrades)
152+
{
153+
Logger.LogInformation($"Trade: {trade.Quantity} lots @ {trade.BuyPrice:F4} -> {trade.SellPrice:F4}, " +
154+
$"Profit: {trade.AbsoluteProfit:F4} ({trade.RelativeProfit:F2}%), " +
155+
$"Period: {(trade.SellDate - trade.BuyDate).TotalMinutes:F1}min");
156+
}
157+
}
158+
Logger.LogInformation("=======================");
159+
}
160+
}
161+
162+
public class ProfitAnalysis
163+
{
164+
public decimal TotalAbsoluteProfit { get; set; }
165+
public decimal TotalRelativeProfit { get; set; }
166+
public decimal RealizedAbsoluteProfit { get; set; }
167+
public decimal RealizedRelativeProfit { get; set; }
168+
public decimal UnrealizedAbsoluteProfit { get; set; }
169+
public decimal UnrealizedRelativeProfit { get; set; }
170+
public decimal TotalInvested { get; set; }
171+
public long OpenPositionQuantity { get; set; }
172+
public decimal AverageBuyPrice { get; set; }
173+
public List<TradeProfit> CompletedTrades { get; set; } = new List<TradeProfit>();
174+
}
175+
176+
public class TradeProfit
177+
{
178+
public DateTime BuyDate { get; set; }
179+
public DateTime SellDate { get; set; }
180+
public long Quantity { get; set; }
181+
public decimal BuyPrice { get; set; }
182+
public decimal SellPrice { get; set; }
183+
public decimal AbsoluteProfit { get; set; }
184+
public decimal RelativeProfit { get; set; }
185+
}

csharp/TraderBot/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
var section = context.Configuration.GetSection(nameof(TradingSettings));
1515
return section.Get<TradingSettings>();
1616
});
17+
services.AddSingleton<ProfitCalculationService>();
1718
services.AddHostedService<TradingService>();
1819
services.AddInvestApiClient((_, settings) =>
1920
{

csharp/TraderBot/TraderBot.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.2" />
1616
<PackageReference Include="Platform.Data.Doublets.Sequences" Version="0.1.1" />
1717
<PackageReference Include="Tinkoff.InvestApi" Version="0.6.1" />
18+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
19+
<PackageReference Include="xunit" Version="2.9.2" />
20+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
21+
<PackageReference Include="Moq" Version="4.20.72" />
1822
</ItemGroup>
1923

2024
</Project>

0 commit comments

Comments
 (0)