diff --git a/apps/backtest-report/components/ReportDetails.tsx b/apps/backtest-report/components/ReportDetails.tsx
index 81fa24377f..6e1edd04b3 100644
--- a/apps/backtest-report/components/ReportDetails.tsx
+++ b/apps/backtest-report/components/ReportDetails.tsx
@@ -177,6 +177,7 @@ const ReportDetails = (props: ReportDetailsProps) => {
const strategyName = props.runID.split("_")[1]
const runID = props.runID.split("_").pop()
const totalProfit = Math.round(reportSummary.symbolReports.map((report) => report.pnl.profit).reduce((prev, cur) => prev + cur) * 100) / 100
+ const totalNetProfit = Math.round((reportSummary.totalNetProfit ?? reportSummary.symbolReports.map((report) => report.pnl.netProfit).reduce((prev, cur) => prev + cur)) * 100) / 100
const totalUnrealizedProfit = Math.round(reportSummary.symbolReports.map((report) => report.pnl.unrealizedProfit).reduce((prev, cur) => prev + cur) * 100) / 100
const totalTrades = reportSummary.symbolReports.map((report) => report.pnl.numTrades).reduce((prev, cur) => prev + cur) || 0
@@ -200,7 +201,8 @@ const ReportDetails = (props: ReportDetailsProps) => {
Run ID: {runID}
= 0 ? "up" : "down"},
+ {title: "Gross Profit", value: "$" + totalProfit.toString(), dir: totalProfit >= 0 ? "up" : "down"},
+ {title: "Net Profit", value: "$" + totalNetProfit.toString(), dir: totalNetProfit >= 0 ? "up" : "down"},
{
title: "Unr. Profit",
value: totalUnrealizedProfit.toString() + "$",
diff --git a/apps/backtest-report/types/report.ts b/apps/backtest-report/types/report.ts
index 5a0f516470..2be5520ca1 100644
--- a/apps/backtest-report/types/report.ts
+++ b/apps/backtest-report/types/report.ts
@@ -31,6 +31,8 @@ export interface ReportSummary {
intervals: string[];
initialTotalBalances: BalanceMap;
finalTotalBalances: BalanceMap;
+ totalProfit?: number;
+ totalNetProfit?: number;
symbolReports: SymbolReport[];
manifests: Manifest[];
}
diff --git a/pkg/backtest/report.go b/pkg/backtest/report.go
index 727718ee36..bf72477c93 100644
--- a/pkg/backtest/report.go
+++ b/pkg/backtest/report.go
@@ -41,8 +41,11 @@ type SummaryReport struct {
InitialEquityValue fixedpoint.Value `json:"initialEquityValue"`
FinalEquityValue fixedpoint.Value `json:"finalEquityValue"`
- // TotalProfit is the profit aggregated from the symbol reports
- TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"`
+ // TotalProfit is the gross profit aggregated from the symbol reports.
+ TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"`
+ // TotalNetProfit is the realized profit after fees aggregated from the
+ // symbol reports.
+ TotalNetProfit fixedpoint.Value `json:"totalNetProfit,omitempty"`
TotalUnrealizedProfit fixedpoint.Value `json:"totalUnrealizedProfit,omitempty"`
TotalGrossProfit fixedpoint.Value `json:"totalGrossProfit,omitempty"`
diff --git a/pkg/cmd/backtest.go b/pkg/cmd/backtest.go
index 4c3b1e258d..603a60b0d7 100644
--- a/pkg/cmd/backtest.go
+++ b/pkg/cmd/backtest.go
@@ -577,7 +577,8 @@ var BacktestCmd = &cobra.Command{
summaryReport.Symbols = append(summaryReport.Symbols, symbol)
summaryReport.SymbolReports = append(summaryReport.SymbolReports, *symbolReport)
- summaryReport.TotalProfit = symbolReport.PnL.Profit
+ summaryReport.TotalProfit = summaryReport.TotalProfit.Add(symbolReport.PnL.Profit)
+ summaryReport.TotalNetProfit = summaryReport.TotalNetProfit.Add(symbolReport.PnL.NetProfit)
summaryReport.TotalUnrealizedProfit = symbolReport.PnL.UnrealizedProfit
summaryReport.InitialEquityValue = summaryReport.InitialEquityValue.Add(symbolReport.InitialEquityValue())
summaryReport.FinalEquityValue = summaryReport.FinalEquityValue.Add(symbolReport.FinalEquityValue())
diff --git a/pkg/strategy/common/profit_fixer_test.go b/pkg/strategy/common/profit_fixer_test.go
index 1d3580f074..bba111ebb4 100644
--- a/pkg/strategy/common/profit_fixer_test.go
+++ b/pkg/strategy/common/profit_fixer_test.go
@@ -155,10 +155,10 @@ func Test_fixFromTrades(t *testing.T) {
assert.NoError(t, err)
assert.True(t, position.Base.IsZero())
- // Fee is deducted from quoteQuantity, so:
- // Buy: avgCost = (10000-10)/1 = 9990
- // Sell: profit = (11000-9990)*1 = 1010
- assert.Equal(t, fixedpoint.NewFromInt(1010).String(), stats.AccumulatedPnL.String())
+ // Quote-currency buy fees increase the cash cost:
+ // Buy: avgCost = (10000+10)/1 = 10010
+ // Sell: profit = (11000-10010)*1 = 990
+ assert.Equal(t, fixedpoint.NewFromInt(990).String(), stats.AccumulatedPnL.String())
})
t.Run("multiple trades with partial closes", func(t *testing.T) {
@@ -210,11 +210,11 @@ func Test_fixFromTrades(t *testing.T) {
assert.NoError(t, err)
assert.True(t, position.Base.IsZero())
- // Buy: avgCost = (20000-20)/2 = 9990
- // First sell: profit = (11000-9990)*1 = 1010
- // Second sell: profit = (12000-9990)*1 = 2010
- // Total: 1010 + 2010 = 3020
- assert.Equal(t, fixedpoint.NewFromInt(3020).String(), stats.AccumulatedPnL.String())
+ // Buy: avgCost = (20000+20)/2 = 10010
+ // First sell: profit = (11000-10010)*1 = 990
+ // Second sell: profit = (12000-10010)*1 = 1990
+ // Total: 990 + 1990 = 2980
+ assert.Equal(t, fixedpoint.NewFromInt(2980).String(), stats.AccumulatedPnL.String())
})
t.Run("short position trades", func(t *testing.T) {
@@ -568,10 +568,9 @@ func Test_fixFromTrades(t *testing.T) {
assert.NoError(t, err)
assert.True(t, position.Base.IsZero())
- // Buy: avgCost = (25000 - 25) / 0.5 = 24975 / 0.5 = 49950 per BTC
- // Sell: profit = (26000 - 49950 * 0.5) = 26000 - 24975 = 1025
- // Note: The fee is already deducted from quoteQuantity in profit calculation
- assert.Equal(t, fixedpoint.NewFromInt(1025).String(), stats.AccumulatedPnL.String())
+ // Buy: avgCost = (25000 + 25) / 0.5 = 25025 / 0.5 = 50050 per BTC
+ // Sell: profit = (26000 - 50050 * 0.5) = 26000 - 25025 = 975
+ assert.Equal(t, fixedpoint.NewFromInt(975).String(), stats.AccumulatedPnL.String())
})
t.Run("quote currency fee - multiple buys then sell", func(t *testing.T) {
@@ -623,10 +622,10 @@ func Test_fixFromTrades(t *testing.T) {
assert.NoError(t, err)
assert.True(t, position.Base.IsZero())
- // First buy: avgCost = (25000 - 12.5) / 0.5 = 24987.5 / 0.5 = 49975 per BTC
- // Second buy: avgCost = ((24987.5 + 25500 - 12.75) / 1 = 50474.75 per BTC
- // Sell: profit = (53000 - 50474.75 * 1) = 2525.25
- assert.Equal(t, "2525.25", stats.AccumulatedPnL.String())
+ // First buy: avgCost = (25000 + 12.5) / 0.5 = 25012.5 / 0.5 = 50025 per BTC
+ // Second buy: avgCost = (25012.5 + 25500 + 12.75) / 1 = 50525.25 per BTC
+ // Sell: profit = (53000 - 50525.25 * 1) = 2474.75
+ assert.Equal(t, "2474.75", stats.AccumulatedPnL.String())
})
t.Run("quote currency fee - short position", func(t *testing.T) {
@@ -738,10 +737,9 @@ func Test_fixFromTrades(t *testing.T) {
assert.NoError(t, err)
assert.True(t, position.Base.IsZero())
- // Buy BTCUSDT at 50000 with 50 USDT fee: avgCost = (50000-50)/1 = 49950
+ // Buy BTCUSDT at 50000 with 50 USDT fee: avgCost = (50000+50)/1 = 50050
// Sell BTCUSDC converted to BTCUSDT: 55000 * 0.998 = 54890 USDT
- // Profit = (54890 - 49950) * 1 = 4940 USDT
- // Note: USDC fee is not converted, so it's ignored in the calculation
- assert.Equal(t, 4940.0, stats.AccumulatedPnL.Float64())
+ // Profit = (54890 - 50050) * 1 = 4840 USDT
+ assert.Equal(t, 4840.0, stats.AccumulatedPnL.Float64())
})
}
diff --git a/pkg/types/position.go b/pkg/types/position.go
index 8f1ca0d5b9..45ef394336 100644
--- a/pkg/types/position.go
+++ b/pkg/types/position.go
@@ -622,6 +622,7 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
price := td.Price
quantity := td.Quantity
quoteQuantity := td.QuoteQuantity
+ quoteCashQuantity := quoteQuantity
fee := td.Fee
if quantity.IsZero() {
@@ -641,8 +642,13 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
}
case p.QuoteCurrency:
+ feeInQuote = fee
if !td.IsFutures {
- quoteQuantity = quoteQuantity.Sub(fee)
+ if td.Side == SideTypeBuy {
+ quoteCashQuantity = quoteQuantity.Add(fee)
+ } else {
+ quoteCashQuantity = quoteQuantity.Sub(fee)
+ }
}
default:
@@ -675,7 +681,7 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
profit = p.AverageCost.Sub(price).Mul(p.Base.Neg())
netProfit = p.AverageCost.Sub(price).Mul(p.Base.Neg()).Sub(feeInQuote)
p.Base = p.Base.Add(quantity)
- p.Quote = p.Quote.Sub(quoteQuantity)
+ p.Quote = p.Quote.Sub(quoteCashQuantity)
p.AverageCost = price
p.AccumulatedProfit = p.AccumulatedProfit.Add(profit)
p.OpenedAt = td.Time.Time()
@@ -683,7 +689,7 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
} else {
// after adding quantity it's still short position
p.Base = p.Base.Add(quantity)
- p.Quote = p.Quote.Sub(quoteQuantity)
+ p.Quote = p.Quote.Sub(quoteCashQuantity)
profit = p.AverageCost.Sub(price).Mul(quantity)
netProfit = p.AverageCost.Sub(price).Mul(quantity).Sub(feeInQuote)
p.AccumulatedProfit = p.AccumulatedProfit.Add(profit)
@@ -706,7 +712,7 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
Div(divisor)
p.Base = p.Base.Add(quantity)
- p.Quote = p.Quote.Sub(quoteQuantity)
+ p.Quote = p.Quote.Sub(quoteCashQuantity)
return fixedpoint.Zero, fixedpoint.Zero, false
case SideTypeSell:
@@ -717,14 +723,14 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
profit = price.Sub(p.AverageCost).Mul(p.Base)
netProfit = price.Sub(p.AverageCost).Mul(p.Base).Sub(feeInQuote)
p.Base = p.Base.Sub(quantity)
- p.Quote = p.Quote.Add(quoteQuantity)
+ p.Quote = p.Quote.Add(quoteCashQuantity)
p.AverageCost = price
p.AccumulatedProfit = p.AccumulatedProfit.Add(profit)
p.OpenedAt = td.Time.Time()
return profit, netProfit, true
} else {
p.Base = p.Base.Sub(quantity)
- p.Quote = p.Quote.Add(quoteQuantity)
+ p.Quote = p.Quote.Add(quoteCashQuantity)
profit = price.Sub(p.AverageCost).Mul(quantity)
netProfit = price.Sub(p.AverageCost).Mul(quantity).Sub(feeInQuote)
p.AccumulatedProfit = p.AccumulatedProfit.Add(profit)
@@ -746,7 +752,7 @@ func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedp
Sub(feeInQuote).
Div(divisor)
p.Base = p.Base.Sub(quantity)
- p.Quote = p.Quote.Add(quoteQuantity)
+ p.Quote = p.Quote.Add(quoteCashQuantity)
return fixedpoint.Zero, fixedpoint.Zero, false
}
diff --git a/pkg/types/position_test.go b/pkg/types/position_test.go
index 1fb7abf490..de0147131f 100644
--- a/pkg/types/position_test.go
+++ b/pkg/types/position_test.go
@@ -152,6 +152,45 @@ func TestPosition_ExchangeFeeRate_Long(t *testing.T) {
assert.Equal(t, expectedProfit, netProfit)
}
+func TestPosition_QuoteFee_LongRoundTrip(t *testing.T) {
+ pos := &Position{
+ Symbol: "BTCUSDT",
+ BaseCurrency: "BTC",
+ QuoteCurrency: "USDT",
+ StrategyInstanceID: "test-position:BTCUSDT",
+ Strategy: "test-position",
+ }
+
+ buyFee := fixedpoint.NewFromFloat(1.0)
+ _, _, madeProfit := pos.AddTrade(Trade{
+ Symbol: "BTCUSDT",
+ Side: SideTypeBuy,
+ Price: fixedpoint.NewFromInt(100),
+ Quantity: fixedpoint.One,
+ QuoteQuantity: fixedpoint.NewFromInt(100),
+ Fee: buyFee,
+ FeeCurrency: "USDT",
+ })
+ assert.False(t, madeProfit)
+ assert.Equal(t, fixedpoint.NewFromInt(101), pos.AverageCost)
+ assert.Equal(t, fixedpoint.NewFromInt(-101), pos.Quote)
+
+ sellFee := fixedpoint.NewFromFloat(1.1)
+ profit, netProfit, madeProfit := pos.AddTrade(Trade{
+ Symbol: "BTCUSDT",
+ Side: SideTypeSell,
+ Price: fixedpoint.NewFromInt(110),
+ Quantity: fixedpoint.One,
+ QuoteQuantity: fixedpoint.NewFromInt(110),
+ Fee: sellFee,
+ FeeCurrency: "USDT",
+ })
+ assert.True(t, madeProfit)
+ assert.Equal(t, fixedpoint.NewFromInt(9), profit)
+ assert.Equal(t, fixedpoint.NewFromFloat(7.9), netProfit)
+ assert.Equal(t, fixedpoint.NewFromFloat(7.9), pos.Quote)
+}
+
func TestPosition(t *testing.T) {
var feeRate float64 = 0.05 * 0.01
feeRateValue := fixedpoint.NewFromFloat(feeRate)
diff --git a/pkg/types/trade_stats.go b/pkg/types/trade_stats.go
index 1bacd7c9ba..3e6e85dfee 100644
--- a/pkg/types/trade_stats.go
+++ b/pkg/types/trade_stats.go
@@ -316,7 +316,7 @@ func (s *TradeStats) Recalculate() {
s.NumOfProfitTrade = fixedpoint.Count(profitsByOrder, fixedpoint.PositiveTester)
s.NumOfLossTrade = fixedpoint.Count(profitsByOrder, fixedpoint.NegativeTester)
- s.TotalNetProfit = fixedpoint.Reduce(profitsByOrder, fixedpoint.SumReducer)
+ s.TotalNetProfit = fixedpoint.Reduce(netProfitsByOrder, fixedpoint.SumReducer)
s.GrossProfit = fixedpoint.Reduce(profitsByOrder, grossProfitReducer)
s.GrossLoss = fixedpoint.Reduce(profitsByOrder, grossLossReducer)
@@ -393,7 +393,7 @@ func (s *TradeStats) add(profit *Profit) {
}
s.lastOrderID = profit.OrderID
- s.TotalNetProfit = s.TotalNetProfit.Add(pnl)
+ s.TotalNetProfit = s.TotalNetProfit.Add(profit.NetProfit)
s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss.Abs())
s.updateWinningRatio()
diff --git a/pkg/types/trade_stats_test.go b/pkg/types/trade_stats_test.go
index 10ed6348a5..5b2acbfb8c 100644
--- a/pkg/types/trade_stats_test.go
+++ b/pkg/types/trade_stats_test.go
@@ -57,3 +57,20 @@ func TestTradeStats_consecutiveCounterAndAmount(t *testing.T) {
assert.Equal(t, "-200", stats.MaximumConsecutiveLoss.String())
assert.Equal(t, 2, stats.MaximumConsecutiveLosses)
}
+
+func TestTradeStats_TotalNetProfitUsesNetProfit(t *testing.T) {
+ stats := NewTradeStats("BTCUSDT")
+ stats.Add(&Profit{
+ Symbol: "BTCUSDT",
+ OrderID: 1,
+ Profit: number("10"),
+ NetProfit: number("8.5"),
+ })
+
+ assert.Equal(t, "8.5", stats.TotalNetProfit.String())
+
+ // Recalculate is used when one order has multiple fills. It must preserve
+ // the same net-profit semantics instead of reverting to gross PnL.
+ stats.Recalculate()
+ assert.Equal(t, "8.5", stats.TotalNetProfit.String())
+}