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
4 changes: 3 additions & 1 deletion apps/backtest-report/components/ReportDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -200,7 +201,8 @@ const ReportDetails = (props: ReportDetailsProps) => {
<Badge key={runID} color="gray" size="xs">Run ID: {runID}</Badge>
</div>
<StatsGridIcons data={[
{title: "Profit", value: "$" + totalProfit.toString(), dir: totalProfit >= 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() + "$",
Expand Down
2 changes: 2 additions & 0 deletions apps/backtest-report/types/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface ReportSummary {
intervals: string[];
initialTotalBalances: BalanceMap;
finalTotalBalances: BalanceMap;
totalProfit?: number;
totalNetProfit?: number;
symbolReports: SymbolReport[];
manifests: Manifest[];
}
Expand Down
7 changes: 5 additions & 2 deletions pkg/backtest/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
3 changes: 2 additions & 1 deletion pkg/cmd/backtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
40 changes: 19 additions & 21 deletions pkg/strategy/common/profit_fixer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
})
}
20 changes: 13 additions & 7 deletions pkg/types/position.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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:
Expand Down Expand Up @@ -675,15 +681,15 @@ 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()
return profit, netProfit, true
} 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)
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
39 changes: 39 additions & 0 deletions pkg/types/position_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions pkg/types/trade_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions pkg/types/trade_stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading