From 8fee2edddf91af8cf213d257af4d0134773d97d6 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:08:37 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=94=A7=20fix(xfundingv2):=20include?= =?UTF-8?q?=20fees=20as=20realized=20PnL=20when=20opening=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/round_pnl.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/strategy/xfundingv2/round_pnl.go b/pkg/strategy/xfundingv2/round_pnl.go index 449a6f5c6e..8c69ebdf4b 100644 --- a/pkg/strategy/xfundingv2/round_pnl.go +++ b/pkg/strategy/xfundingv2/round_pnl.go @@ -56,14 +56,15 @@ func (r *ArbitrageRound) RealizedPnL() *RoundRealizedPnL { } func (r *ArbitrageRound) realizedPnL() *RoundRealizedPnL { - fundingIncome := r.totalFundingIncome() spotMarket := r.spotWorker.Market() futuresMarket := r.futuresWorker.Market() spotPosition := types.NewPositionFromMarket(spotMarket) + spotPosition.UseExcludeFeeFromCostMode() futuresPosition := types.NewPositionFromMarket(futuresMarket) + futuresPosition.UseExcludeFeeFromCostMode() if r.spotExchangeFeeRates != nil { spotPosition.ExchangeFeeRates = r.spotExchangeFeeRates } From e9822a33461550899579db31b0f78d98e68db143 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:44:54 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=A7=20fix(strategy):=20cronjob-lik?= =?UTF-8?q?e=20stats=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/strategy.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/strategy/xfundingv2/strategy.go b/pkg/strategy/xfundingv2/strategy.go index 93effd48f2..45d9b4604a 100644 --- a/pkg/strategy/xfundingv2/strategy.go +++ b/pkg/strategy/xfundingv2/strategy.go @@ -691,7 +691,8 @@ func (s *Strategy) CrossRun( // setup callbacks s.spotSession.MarketDataStream.OnKLineClosed(types.KLineWith(s.TickSymbol, s.TickInterval, func(kline types.KLine) { - s.tick(s.ctx, kline.EndTime.Time()) + // end time of the kline is hh:mm:ss.999 -> add 1ms to round it up + s.tick(s.ctx, kline.EndTime.Time().Add(time.Millisecond)) })) // trade update callbacks @@ -1058,9 +1059,16 @@ func (s *Strategy) tick(ctx context.Context, tickTime time.Time) { s.processPendingRounds(ctx, tickTime) // 5. log stats - if s.lastStatsTime.IsZero() || tickTime.Sub(s.lastStatsTime) >= s.StatsPeriod.Duration() { + var notifyStats bool + period := s.StatsPeriod.Duration() + if s.lastStatsTime.IsZero() { + notifyStats = true + } else { + notifyStats = tickTime.Sub(s.lastStatsTime) >= period + } + if notifyStats { s.notifyStats() - s.lastStatsTime = tickTime + s.lastStatsTime = tickTime.Truncate(period) } } From e67f076b5802ba52c8883ba815351a0006d7d5de Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:49:09 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=94=A7=20fix(xfundingv2):=20simplify?= =?UTF-8?q?=20legUnrealizedPnL=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/round_pnl.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/strategy/xfundingv2/round_pnl.go b/pkg/strategy/xfundingv2/round_pnl.go index 8c69ebdf4b..1e3c216b3f 100644 --- a/pkg/strategy/xfundingv2/round_pnl.go +++ b/pkg/strategy/xfundingv2/round_pnl.go @@ -160,18 +160,15 @@ func (p *RoundUnrealizedPnL) TotalPnL() fixedpoint.Value { return p.RoundRealizedPnL.TotalPnL().Add(p.UnrealizedSpotPnL).Add(p.UnrealizedFuturesPnL) } -// legUnrealizedPnL marks one leg to market. Base is signed, so the PnL is simply -// (price - averageCost) * base for both long and short. The close-side price is the -// best bid for a long leg (sell to close) and the best ask for a short leg (buy to -// close). Returns zero when the position is flat or the relevant book side is empty. +// legUnrealizedPnL marks one leg to market. +// If the position is long, it uses the best bid price; Otherwise, it uses the best ask price. func legUnrealizedPnL(book types.OrderBook, position *types.Position) (fixedpoint.Value, fixedpoint.Value) { - base := position.Base - if base.IsZero() { + if position.IsClosed() { return fixedpoint.Zero, fixedpoint.Zero } var price fixedpoint.Value - if base.Sign() > 0 { + if position.IsLong() { bid, ok := book.BestBid() if !ok { return fixedpoint.Zero, fixedpoint.Zero @@ -185,5 +182,5 @@ func legUnrealizedPnL(book types.OrderBook, position *types.Position) (fixedpoin price = ask.Price } - return price, price.Sub(position.AverageCost).Mul(base) + return price, position.UnrealizedProfit(price) } From 72168a4bdb3859ae211e06ab0582e1ae6139921c Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:49:33 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=A7=20fix(xfundingv2):=20enable=20?= =?UTF-8?q?ExcludeFeeFromCost=20mode=20for=20spot=20and=20futures=20positi?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/strategy.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/strategy/xfundingv2/strategy.go b/pkg/strategy/xfundingv2/strategy.go index 45d9b4604a..26ba4eae61 100644 --- a/pkg/strategy/xfundingv2/strategy.go +++ b/pkg/strategy/xfundingv2/strategy.go @@ -481,12 +481,14 @@ func (s *Strategy) CrossRun( spotPosition = types.NewPositionFromMarket(spotMarket) s.SpotPositions[symbol] = spotPosition } + spotPosition.UseExcludeFeeFromCostMode() if p, found := s.FuturesPositions[symbol]; found { futuresPosition = p } else { futuresPosition = types.NewPositionFromMarket(futuresMarket) s.FuturesPositions[symbol] = futuresPosition } + futuresPosition.UseExcludeFeeFromCostMode() spotExecutor := bbgo.NewGeneralOrderExecutor( s.spotSession, symbol, @@ -550,6 +552,7 @@ func (s *Strategy) CrossRun( s.InstanceID(), spotPosition, ) + spotExecutor.OrderStore().AddOrderUpdate = true spotExecutor.DisableNotify() spotExecutor.Bind() s.spotGeneralOrderExecutors[s.FeeSymbol] = spotExecutor From 64c1cfdaa1892fb407028cf6d03c9e1b8aa068f6 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:08:58 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=84=20refactor(xfundingv2):=20rest?= =?UTF-8?q?ructure=20round=20record=20types=20and=20moving=20shared=20fiel?= =?UTF-8?q?ds=20to=20base=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/round_record.go | 153 +++++++++++++----------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/pkg/strategy/xfundingv2/round_record.go b/pkg/strategy/xfundingv2/round_record.go index 2d0308f637..3277dc9f86 100644 --- a/pkg/strategy/xfundingv2/round_record.go +++ b/pkg/strategy/xfundingv2/round_record.go @@ -15,10 +15,7 @@ import ( "github.com/c9s/bbgo/pkg/types" ) -// ClosedRoundRecord is the summary of a closed arbitrage round persisted to the DB. -// The `db` tags name the xfundingv2_closed_rounds columns and are the single source -// of truth for the insert column list and values (see dbColumnsOf / dbValuesOf). -type ClosedRoundRecord struct { +type RoundRecordBase struct { // ID is the round's own DB-independent identifier (UUID generated at creation) ID string `db:"id"` @@ -38,15 +35,24 @@ type ClosedRoundRecord struct { AnnualizedRate fixedpoint.Value `db:"annualized_rate"` FundingIncome fixedpoint.Value `db:"funding_income"` + // realized PnLs SpotPnL fixedpoint.Value `db:"spot_pnl"` SpotNetPnL fixedpoint.Value `db:"spot_net_pnl"` FuturesPnL fixedpoint.Value `db:"futures_pnl"` FuturesNetPnL fixedpoint.Value `db:"futures_net_pnl"` NetPnL fixedpoint.Value `db:"net_pnl"` + StartedAt time.Time `db:"started_at"` +} + +// ClosedRoundRecord is the summary of a closed arbitrage round persisted to the DB. +// The `db` tags name the xfundingv2_closed_rounds columns and are the single source +// of truth for the insert column list and values (see dbColumnsOf / dbValuesOf). +type ClosedRoundRecord struct { + RoundRecordBase + NumHoldingIntervals int `db:"num_holding_intervals"` - StartAt time.Time `db:"started_at"` ReadyAt *time.Time `db:"ready_at"` ClosingAt time.Time `db:"closing_at"` ClosedAt time.Time `db:"closed_at"` @@ -117,32 +123,35 @@ func (s *RoundInsertService) newClosedRoundRecord(round *ArbitrageRound) (Closed readyTime = &ts } record := ClosedRoundRecord{ - ID: round.ID(), - InstanceID: s.instanceID, + RoundRecordBase: RoundRecordBase{ + ID: round.ID(), + InstanceID: s.instanceID, - SpotSymbol: round.SpotSymbol(), - FuturesSymbol: round.FuturesSymbol(), + SpotSymbol: round.SpotSymbol(), + FuturesSymbol: round.FuturesSymbol(), - SpotExchange: round.syncState.SpotExchangeName.String(), - FuturesExchange: round.syncState.FuturesExchangeName.String(), + SpotExchange: round.syncState.SpotExchangeName.String(), + FuturesExchange: round.syncState.FuturesExchangeName.String(), - Direction: string(round.syncState.DirectionPolicy.Direction), - CollateralAsset: round.CollateralAsset(), + Direction: string(round.syncState.DirectionPolicy.Direction), + CollateralAsset: round.CollateralAsset(), - Leverage: round.syncState.Leverage, - TriggeredFundingRate: round.TriggeredFundingRate(), - AnnualizedRate: round.AnnualizedRate(), - FundingIncome: pnl.FundingIncome, + Leverage: round.syncState.Leverage, + TriggeredFundingRate: round.TriggeredFundingRate(), + AnnualizedRate: round.AnnualizedRate(), + FundingIncome: pnl.FundingIncome, - SpotPnL: pnl.SpotProfitStats.AccumulatedPnL, - SpotNetPnL: pnl.SpotProfitStats.AccumulatedNetProfit, - FuturesPnL: pnl.FuturesProfitStats.AccumulatedPnL, - FuturesNetPnL: pnl.FuturesProfitStats.AccumulatedNetProfit, - NetPnL: pnl.NetPnL(), + SpotPnL: pnl.SpotProfitStats.AccumulatedPnL, + SpotNetPnL: pnl.SpotProfitStats.AccumulatedNetProfit, + FuturesPnL: pnl.FuturesProfitStats.AccumulatedPnL, + FuturesNetPnL: pnl.FuturesProfitStats.AccumulatedNetProfit, + NetPnL: pnl.NetPnL(), + + StartedAt: round.StartedAt(), + }, NumHoldingIntervals: round.NumHoldingIntervals(round.ClosedAt()), - StartAt: round.StartedAt(), ReadyAt: readyTime, ClosingAt: round.ClosingAt(), ClosedAt: round.ClosedAt(), @@ -222,23 +231,7 @@ func (s *RoundInsertService) insertClosedRound( // persisted to the DB. The `db` tags name the xfundingv2_round_snapshots columns and // are the single source of truth for the insert column list and values. type ActiveRoundRecord struct { - ID string `db:"id"` - - InstanceID string `db:"strategy_instance_id"` - - SpotSymbol string `db:"spot_symbol"` - FuturesSymbol string `db:"futures_symbol"` - - SpotExchange string `db:"spot_exchange"` - FuturesExchange string `db:"futures_exchange"` - - Direction string `db:"direction"` - CollateralAsset string `db:"collateral_asset"` - - Leverage fixedpoint.Value `db:"leverage"` - TriggeredFundingRate fixedpoint.Value `db:"triggered_funding_rate"` - AnnualizedRate fixedpoint.Value `db:"annualized_rate"` - FundingIncome fixedpoint.Value `db:"funding_income"` + RoundRecordBase SpotPosition fixedpoint.Value `db:"spot_position"` FuturesPosition fixedpoint.Value `db:"futures_position"` @@ -252,13 +245,6 @@ type ActiveRoundRecord struct { SpotAverageCost fixedpoint.Value `db:"spot_average_cost"` FuturesAverageCost fixedpoint.Value `db:"futures_average_cost"` - // realized PnLs - SpotPnL fixedpoint.Value `db:"spot_pnl"` - SpotNetPnL fixedpoint.Value `db:"spot_net_pnl"` - FuturesPnL fixedpoint.Value `db:"futures_pnl"` - FuturesNetPnL fixedpoint.Value `db:"futures_net_pnl"` - NetPnL fixedpoint.Value `db:"net_pnl"` - // unrealized PnLs UnrealizedSpotPnL fixedpoint.Value `db:"unrealized_spot_pnl"` UnrealizedFuturesPnL fixedpoint.Value `db:"unrealized_futures_pnl"` @@ -266,8 +252,6 @@ type ActiveRoundRecord struct { TotalNetPnL fixedpoint.Value `db:"total_net_pnl"` TotalSpotNetPnL fixedpoint.Value `db:"total_spot_net_pnl"` TotalFuturesNetPnL fixedpoint.Value `db:"total_futures_net_pnl"` - - StartedAt time.Time `db:"started_at"` } // newActiveRoundRecord builds a point-in-time snapshot of an active (non-closed) @@ -281,22 +265,32 @@ func (s *RoundInsertService) newActiveRoundRecord( pnl := round.UnrealizedPnL(spotOrderBook, futuresOrderBook) record := ActiveRoundRecord{ - ID: round.ID(), - InstanceID: s.instanceID, + RoundRecordBase: RoundRecordBase{ + ID: round.ID(), + InstanceID: s.instanceID, + + SpotSymbol: round.SpotSymbol(), + FuturesSymbol: round.FuturesSymbol(), - SpotSymbol: round.SpotSymbol(), - FuturesSymbol: round.FuturesSymbol(), + SpotExchange: round.syncState.SpotExchangeName.String(), + FuturesExchange: round.syncState.FuturesExchangeName.String(), - SpotExchange: round.syncState.SpotExchangeName.String(), - FuturesExchange: round.syncState.FuturesExchangeName.String(), + Direction: string(round.syncState.DirectionPolicy.Direction), + CollateralAsset: round.CollateralAsset(), - Direction: string(round.syncState.DirectionPolicy.Direction), - CollateralAsset: round.CollateralAsset(), + Leverage: round.syncState.Leverage, + TriggeredFundingRate: round.TriggeredFundingRate(), + AnnualizedRate: round.AnnualizedRate(), + FundingIncome: pnl.FundingIncome, - Leverage: round.syncState.Leverage, - TriggeredFundingRate: round.TriggeredFundingRate(), - AnnualizedRate: round.AnnualizedRate(), - FundingIncome: pnl.FundingIncome, + SpotPnL: pnl.SpotProfitStats.AccumulatedPnL, + SpotNetPnL: pnl.SpotProfitStats.AccumulatedNetProfit, + FuturesPnL: pnl.FuturesProfitStats.AccumulatedPnL, + FuturesNetPnL: pnl.FuturesProfitStats.AccumulatedNetProfit, + NetPnL: pnl.NetPnL(), + + StartedAt: round.StartedAt(), + }, SpotPosition: pnl.SpotPosition.Base, FuturesPosition: pnl.FuturesPosition.Base, @@ -307,20 +301,12 @@ func (s *RoundInsertService) newActiveRoundRecord( SpotAverageCost: pnl.SpotPosition.AverageCost, FuturesAverageCost: pnl.FuturesPosition.AverageCost, - SpotPnL: pnl.SpotProfitStats.AccumulatedPnL, - SpotNetPnL: pnl.SpotProfitStats.AccumulatedNetProfit, - FuturesPnL: pnl.FuturesProfitStats.AccumulatedPnL, - FuturesNetPnL: pnl.FuturesProfitStats.AccumulatedNetProfit, - NetPnL: pnl.NetPnL(), - UnrealizedSpotPnL: pnl.UnrealizedSpotPnL, UnrealizedFuturesPnL: pnl.UnrealizedFuturesPnL, TotalNetPnL: pnl.TotalPnL(), TotalSpotNetPnL: pnl.TotalSpotNetPnL(), TotalFuturesNetPnL: pnl.TotalFuturesNetPnL(), - - StartedAt: round.StartedAt(), } return record, newFundingFeeRecords(round) @@ -462,8 +448,33 @@ func extractDBColumns(record any) ([]string, []any) { colnames := make([]string, 0, rt.NumField()) values := make([]any, 0, rt.NumField()) for i := 0; i < rt.NumField(); i++ { - if col, ok := dbColumnName(rt.Field(i)); ok { - value := rv.Field(i).Interface() + field := rt.Field(i) + fieldValue := rv.Field(i) + + // Recurse into embedded (anonymous) structs that carry no db tag of their + // own, so their db-tagged fields are promoted as columns of the parent. + if field.Anonymous { + ft := field.Type + fv := fieldValue + for ft.Kind() == reflect.Ptr { + if fv.IsNil() { + break + } + ft = ft.Elem() + fv = fv.Elem() + } + if ft.Kind() == reflect.Struct { + if _, tagged := field.Tag.Lookup("db"); !tagged { + embeddedCols, embeddedVals := extractDBColumns(fv.Interface()) + colnames = append(colnames, embeddedCols...) + values = append(values, embeddedVals...) + continue + } + } + } + + if col, ok := dbColumnName(field); ok { + value := fieldValue.Interface() colnames = append(colnames, col) values = append(values, value) } From bc0d5b7049ab446648816c01631663606f731e30 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:09:50 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A7=AA=20test(xfundingv2):=20update?= =?UTF-8?q?=20round=20record=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/strategy/xfundingv2/round_record_test.go | 130 ++++++++++--------- 1 file changed, 69 insertions(+), 61 deletions(-) diff --git a/pkg/strategy/xfundingv2/round_record_test.go b/pkg/strategy/xfundingv2/round_record_test.go index dcd9799d1a..95d5598f41 100644 --- a/pkg/strategy/xfundingv2/round_record_test.go +++ b/pkg/strategy/xfundingv2/round_record_test.go @@ -83,28 +83,30 @@ func TestRoundInsertService_ClosedRound(t *testing.T) { readyTime := time.Now().Add(-23 * time.Hour) record := ClosedRoundRecord{ - ID: roundID, - InstanceID: svc.instanceID, - SpotSymbol: "BTCUSDT", - FuturesSymbol: "BTCUSDT", - SpotExchange: "binance", - FuturesExchange: "binance", - Direction: "short", - CollateralAsset: "BTC", - Leverage: fixedpoint.NewFromInt(3), - TriggeredFundingRate: fixedpoint.NewFromFloat(0.0003), - AnnualizedRate: fixedpoint.NewFromFloat(0.32), - FundingIncome: fixedpoint.NewFromFloat(12.5), - SpotPnL: fixedpoint.NewFromFloat(-1.2), - SpotNetPnL: fixedpoint.NewFromFloat(-1.5), - FuturesPnL: fixedpoint.NewFromFloat(0.8), - FuturesNetPnL: fixedpoint.NewFromFloat(0.5), - NetPnL: fixedpoint.NewFromFloat(11.5), - NumHoldingIntervals: 3, - StartAt: time.Now().Add(-24 * time.Hour), - ReadyAt: &readyTime, - ClosingAt: time.Now().Add(-2 * time.Hour), - ClosedAt: time.Now().Add(-time.Hour), + RoundRecordBase: RoundRecordBase{ + ID: roundID, + InstanceID: svc.instanceID, + SpotSymbol: "BTCUSDT", + FuturesSymbol: "BTCUSDT", + SpotExchange: "binance", + FuturesExchange: "binance", + Direction: "short", + CollateralAsset: "BTC", + Leverage: fixedpoint.NewFromInt(3), + TriggeredFundingRate: fixedpoint.NewFromFloat(0.0003), + AnnualizedRate: fixedpoint.NewFromFloat(0.32), + FundingIncome: fixedpoint.NewFromFloat(12.5), + SpotPnL: fixedpoint.NewFromFloat(-1.2), + SpotNetPnL: fixedpoint.NewFromFloat(-1.5), + FuturesPnL: fixedpoint.NewFromFloat(0.8), + FuturesNetPnL: fixedpoint.NewFromFloat(0.5), + NetPnL: fixedpoint.NewFromFloat(11.5), + StartedAt: time.Now().Add(-24 * time.Hour), + }, + NumHoldingIntervals: 3, + ReadyAt: &readyTime, + ClosingAt: time.Now().Add(-2 * time.Hour), + ClosedAt: time.Now().Add(-time.Hour), } fees := []FundingFee{ @@ -140,17 +142,19 @@ func TestRoundInsertService_ClosedRound(t *testing.T) { // a closed round always has start/closing/closed times; ready_time is left unset // to exercise the nullable-ready_time path (a round closed before it became ready). err := svc.insertClosedRound(ClosedRoundRecord{ - ID: roundID, - InstanceID: svc.instanceID, - SpotSymbol: "ETHUSDT", - FuturesSymbol: "ETHUSDT", - SpotExchange: "binance", - FuturesExchange: "binance", - Direction: "long", - CollateralAsset: "USDT", - StartAt: time.Now().Add(-24 * time.Hour), - ClosingAt: time.Now().Add(-2 * time.Hour), - ClosedAt: time.Now().Add(-time.Hour), + RoundRecordBase: RoundRecordBase{ + ID: roundID, + InstanceID: svc.instanceID, + SpotSymbol: "ETHUSDT", + FuturesSymbol: "ETHUSDT", + SpotExchange: "binance", + FuturesExchange: "binance", + Direction: "long", + CollateralAsset: "USDT", + StartedAt: time.Now().Add(-24 * time.Hour), + }, + ClosingAt: time.Now().Add(-2 * time.Hour), + ClosedAt: time.Now().Add(-time.Hour), }, nil) require.NoError(t, err) @@ -184,27 +188,29 @@ func TestRoundInsertService_ActiveRoundSnapshot(t *testing.T) { cleanupRound(t, db, roundID) record := ActiveRoundRecord{ - ID: roundID, - InstanceID: svc.instanceID, - SpotSymbol: "BTCUSDT", - FuturesSymbol: "BTCUSDT", - SpotExchange: "binance", - FuturesExchange: "binance", - Direction: "short", - CollateralAsset: "BTC", - Leverage: fixedpoint.NewFromInt(3), - TriggeredFundingRate: fixedpoint.NewFromFloat(0.0003), - AnnualizedRate: fixedpoint.NewFromFloat(0.32), - FundingIncome: fixedpoint.NewFromFloat(4.2), + RoundRecordBase: RoundRecordBase{ + ID: roundID, + InstanceID: svc.instanceID, + SpotSymbol: "BTCUSDT", + FuturesSymbol: "BTCUSDT", + SpotExchange: "binance", + FuturesExchange: "binance", + Direction: "short", + CollateralAsset: "BTC", + Leverage: fixedpoint.NewFromInt(3), + TriggeredFundingRate: fixedpoint.NewFromFloat(0.0003), + AnnualizedRate: fixedpoint.NewFromFloat(0.32), + FundingIncome: fixedpoint.NewFromFloat(4.2), + SpotPnL: fixedpoint.NewFromFloat(-0.5), + SpotNetPnL: fixedpoint.NewFromFloat(-0.8), + FuturesPnL: fixedpoint.NewFromFloat(0.3), + FuturesNetPnL: fixedpoint.NewFromFloat(0.1), + NetPnL: fixedpoint.NewFromFloat(3.5), + }, SpotPosition: fixedpoint.NewFromFloat(1.0), FuturesPosition: fixedpoint.NewFromFloat(-1.0), SpotAverageCost: fixedpoint.NewFromFloat(40000), FuturesAverageCost: fixedpoint.NewFromFloat(40100), - SpotPnL: fixedpoint.NewFromFloat(-0.5), - SpotNetPnL: fixedpoint.NewFromFloat(-0.8), - FuturesPnL: fixedpoint.NewFromFloat(0.3), - FuturesNetPnL: fixedpoint.NewFromFloat(0.1), - NetPnL: fixedpoint.NewFromFloat(3.5), UnrealizedSpotPnL: fixedpoint.NewFromFloat(1000), TotalSpotNetPnL: fixedpoint.NewFromFloat(999.2), UnrealizedFuturesPnL: fixedpoint.NewFromFloat(100), @@ -262,17 +268,19 @@ func TestRoundInsertService_ActiveRoundSnapshot(t *testing.T) { // when the round is later closed, insertClosedRound persists the same funding // fees again; the upsert must not collide with the rows written while active. require.NoError(t, svc.insertClosedRound(ClosedRoundRecord{ - ID: roundID, - InstanceID: svc.instanceID, - SpotSymbol: "BTCUSDT", - FuturesSymbol: "BTCUSDT", - SpotExchange: "binance", - FuturesExchange: "binance", - Direction: "short", - CollateralAsset: "BTC", - StartAt: time.Now().Add(-24 * time.Hour), - ClosingAt: time.Now().Add(-2 * time.Hour), - ClosedAt: time.Now().Add(-time.Hour), + RoundRecordBase: RoundRecordBase{ + ID: roundID, + InstanceID: svc.instanceID, + SpotSymbol: "BTCUSDT", + FuturesSymbol: "BTCUSDT", + SpotExchange: "binance", + FuturesExchange: "binance", + Direction: "short", + CollateralAsset: "BTC", + StartedAt: time.Now().Add(-24 * time.Hour), + }, + ClosingAt: time.Now().Add(-2 * time.Hour), + ClosedAt: time.Now().Add(-time.Hour), }, fees)) require.NoError(t, db.Get(&feeCount, "SELECT COUNT(*) FROM xfundingv2_funding_fees WHERE round_id = ?", roundID)) From 9fa918275d0924ac75058c4fae614040126875ae Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:59:40 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A7=20migration(xfundingv2):=20add?= =?UTF-8?q?=20fee=5Fsymbol=20and=20fee=5Favg=5Fcost=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xfundingv2_20260722093545_add_fee_cost.go | 21 ++++++++++++++ .../xfundingv2_20260722093545_add_fee_cost.go | 25 +++++++++++++++++ .../mysql/20260722093545_add_fee_cost.sql | 26 +++++++++++++++++ .../sqlite3/20260722093545_add_fee_cost.sql | 28 +++++++++++++++++++ pkg/strategy/xfundingv2/round_record.go | 10 +++++++ 5 files changed, 110 insertions(+) create mode 100644 pkg/migrations/mysql/xfundingv2_20260722093545_add_fee_cost.go create mode 100644 pkg/migrations/sqlite3/xfundingv2_20260722093545_add_fee_cost.go create mode 100644 pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql create mode 100644 pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql diff --git a/pkg/migrations/mysql/xfundingv2_20260722093545_add_fee_cost.go b/pkg/migrations/mysql/xfundingv2_20260722093545_add_fee_cost.go new file mode 100644 index 0000000000..4ae85494bc --- /dev/null +++ b/pkg/migrations/mysql/xfundingv2_20260722093545_add_fee_cost.go @@ -0,0 +1,21 @@ +package mysql + +import ( + "github.com/c9s/rockhopper/v2" +) + +// This migration was compiled from pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql. +// The SQL statements are registered as data so they can be previewed in the +// console while the migration runs, exactly like a raw .sql migration. +func init() { + AddStatementMigration("xfundingv2", 20260722093545, "pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql", true, + []rockhopper.Statement{ + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_closed_rounds`\n ADD COLUMN `fee_symbol` VARCHAR(32) NOT NULL DEFAULT '' AFTER `funding_income`,\n ADD COLUMN `fee_avg_cost` DECIMAL(20, 8) NOT NULL DEFAULT 0 AFTER `fee_symbol`;"}, + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_round_snapshots`\n ADD COLUMN `fee_symbol` VARCHAR(32) NOT NULL DEFAULT '' AFTER `funding_income`,\n ADD COLUMN `fee_avg_cost` DECIMAL(20, 8) NOT NULL DEFAULT 0 AFTER `fee_symbol`;"}, + }, + []rockhopper.Statement{ + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_round_snapshots`\n DROP COLUMN `fee_avg_cost`,\n DROP COLUMN `fee_symbol`;"}, + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_closed_rounds`\n DROP COLUMN `fee_avg_cost`,\n DROP COLUMN `fee_symbol`;"}, + }, + ) +} diff --git a/pkg/migrations/sqlite3/xfundingv2_20260722093545_add_fee_cost.go b/pkg/migrations/sqlite3/xfundingv2_20260722093545_add_fee_cost.go new file mode 100644 index 0000000000..79998ea4ab --- /dev/null +++ b/pkg/migrations/sqlite3/xfundingv2_20260722093545_add_fee_cost.go @@ -0,0 +1,25 @@ +package sqlite3 + +import ( + "github.com/c9s/rockhopper/v2" +) + +// This migration was compiled from pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql. +// The SQL statements are registered as data so they can be previewed in the +// console while the migration runs, exactly like a raw .sql migration. +func init() { + AddStatementMigration("xfundingv2", 20260722093545, "pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql", true, + []rockhopper.Statement{ + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_closed_rounds` ADD COLUMN `fee_symbol` TEXT NOT NULL DEFAULT '';"}, + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_closed_rounds` ADD COLUMN `fee_avg_cost` REAL NOT NULL DEFAULT 0;"}, + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_round_snapshots` ADD COLUMN `fee_symbol` TEXT NOT NULL DEFAULT '';"}, + {Direction: rockhopper.DirectionUp, SQL: "ALTER TABLE `xfundingv2_round_snapshots` ADD COLUMN `fee_avg_cost` REAL NOT NULL DEFAULT 0;"}, + }, + []rockhopper.Statement{ + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_round_snapshots` DROP COLUMN `fee_avg_cost`;"}, + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_round_snapshots` DROP COLUMN `fee_symbol`;"}, + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_closed_rounds` DROP COLUMN `fee_avg_cost`;"}, + {Direction: rockhopper.DirectionDown, SQL: "ALTER TABLE `xfundingv2_closed_rounds` DROP COLUMN `fee_symbol`;"}, + }, + ) +} diff --git a/pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql b/pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql new file mode 100644 index 0000000000..37d49750f0 --- /dev/null +++ b/pkg/strategy/xfundingv2/migrations/mysql/20260722093545_add_fee_cost.sql @@ -0,0 +1,26 @@ +-- @package xfundingv2 +-- +up +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` + ADD COLUMN `fee_symbol` VARCHAR(32) NOT NULL DEFAULT '' AFTER `funding_income`, + ADD COLUMN `fee_avg_cost` DECIMAL(20, 8) NOT NULL DEFAULT 0 AFTER `fee_symbol`; +-- +end + +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` + ADD COLUMN `fee_symbol` VARCHAR(32) NOT NULL DEFAULT '' AFTER `funding_income`, + ADD COLUMN `fee_avg_cost` DECIMAL(20, 8) NOT NULL DEFAULT 0 AFTER `fee_symbol`; +-- +end + +-- +down +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` + DROP COLUMN `fee_avg_cost`, + DROP COLUMN `fee_symbol`; +-- +end + +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` + DROP COLUMN `fee_avg_cost`, + DROP COLUMN `fee_symbol`; +-- +end diff --git a/pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql b/pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql new file mode 100644 index 0000000000..250d59e312 --- /dev/null +++ b/pkg/strategy/xfundingv2/migrations/sqlite3/20260722093545_add_fee_cost.sql @@ -0,0 +1,28 @@ +-- @package xfundingv2 +-- +up +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` ADD COLUMN `fee_symbol` TEXT NOT NULL DEFAULT ''; +-- +end +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` ADD COLUMN `fee_avg_cost` REAL NOT NULL DEFAULT 0; +-- +end +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` ADD COLUMN `fee_symbol` TEXT NOT NULL DEFAULT ''; +-- +end +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` ADD COLUMN `fee_avg_cost` REAL NOT NULL DEFAULT 0; +-- +end + +-- +down +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` DROP COLUMN `fee_avg_cost`; +-- +end +-- +begin +ALTER TABLE `xfundingv2_round_snapshots` DROP COLUMN `fee_symbol`; +-- +end +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` DROP COLUMN `fee_avg_cost`; +-- +end +-- +begin +ALTER TABLE `xfundingv2_closed_rounds` DROP COLUMN `fee_symbol`; +-- +end diff --git a/pkg/strategy/xfundingv2/round_record.go b/pkg/strategy/xfundingv2/round_record.go index 3277dc9f86..505d3ebfed 100644 --- a/pkg/strategy/xfundingv2/round_record.go +++ b/pkg/strategy/xfundingv2/round_record.go @@ -35,6 +35,10 @@ type RoundRecordBase struct { AnnualizedRate fixedpoint.Value `db:"annualized_rate"` FundingIncome fixedpoint.Value `db:"funding_income"` + // fee + FeeSymbol string `db:"fee_symbol"` + FeeAvgCost fixedpoint.Value `db:"fee_avg_cost"` + // realized PnLs SpotPnL fixedpoint.Value `db:"spot_pnl"` SpotNetPnL fixedpoint.Value `db:"spot_net_pnl"` @@ -147,6 +151,9 @@ func (s *RoundInsertService) newClosedRoundRecord(round *ArbitrageRound) (Closed FuturesNetPnL: pnl.FuturesProfitStats.AccumulatedNetProfit, NetPnL: pnl.NetPnL(), + FeeSymbol: round.syncState.FeeSymbol, + FeeAvgCost: round.syncState.AvgFeeCost, + StartedAt: round.StartedAt(), }, @@ -283,6 +290,9 @@ func (s *RoundInsertService) newActiveRoundRecord( AnnualizedRate: round.AnnualizedRate(), FundingIncome: pnl.FundingIncome, + FeeSymbol: round.syncState.FeeSymbol, + FeeAvgCost: round.syncState.AvgFeeCost, + SpotPnL: pnl.SpotProfitStats.AccumulatedPnL, SpotNetPnL: pnl.SpotProfitStats.AccumulatedNetProfit, FuturesPnL: pnl.FuturesProfitStats.AccumulatedPnL,