feat: enhance backtest with real-time positions, P&L fixes, and strategy integration

- Add real-time position display with unrealized P&L during backtest
- Fix P&L calculation by tracking accumulated opening fees
- Add strategy coin source resolution (AI500, OI Top, mixed)
- Infer AI provider from model name for better compatibility
- Cap position size to available margin to prevent insufficient cash errors
- Fix trade markers on K-line chart (long/short instead of buy/sell)
- Add QuantData and OI ranking to backtest decision context
This commit is contained in:
tinkle-community
2025-12-20 01:10:11 +08:00
parent 5534861fe5
commit e2d702c662
9 changed files with 1144 additions and 62 deletions

View File

@@ -18,6 +18,7 @@ type position struct {
Notional float64
LiquidationPrice float64
OpenTime int64
AccumulatedFee float64 // Total fees paid (opening + any additions)
}
type BacktestAccount struct {
@@ -87,6 +88,7 @@ func (acc *BacktestAccount) Open(symbol, side string, quantity float64, leverage
pos.Notional = notional
pos.OpenTime = ts
pos.LiquidationPrice = computeLiquidation(execPrice, leverage, side)
pos.AccumulatedFee = fee // Track opening fee
} else {
if leverage != pos.Leverage {
// Use weighted average leverage (approximate)
@@ -98,6 +100,7 @@ func (acc *BacktestAccount) Open(symbol, side string, quantity float64, leverage
pos.EntryPrice = ((pos.EntryPrice * pos.Quantity) + execPrice*quantity) / (pos.Quantity + quantity)
pos.Quantity += quantity
pos.LiquidationPrice = computeLiquidation(pos.EntryPrice, pos.Leverage, side)
pos.AccumulatedFee += fee // Add to accumulated fee for position additions
}
return pos, fee, execPrice, nil
@@ -120,23 +123,32 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
execPrice := applySlippage(price, acc.slippageRate, side, false)
notional := execPrice * quantity
fee := notional * acc.feeRate
closingFee := notional * acc.feeRate
// Calculate proportional opening fee for the quantity being closed
closePortion := quantity / pos.Quantity
openingFeePortion := pos.AccumulatedFee * closePortion
totalFee := closingFee + openingFeePortion
realized := realizedPnL(pos, quantity, execPrice)
marginPortion := pos.Margin * (quantity / pos.Quantity)
acc.cash += marginPortion + realized - fee
acc.realizedPnL += realized - fee
marginPortion := pos.Margin * closePortion
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
acc.cash += marginPortion + realized - closingFee
// But for realized P&L tracking, we include both fees
acc.realizedPnL += realized - totalFee
pos.Quantity -= quantity
pos.Notional -= notional
pos.Margin -= marginPortion
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
if pos.Quantity <= epsilon {
acc.removePosition(pos)
}
return realized, fee, execPrice, nil
// Return total fee (opening + closing) so caller can calculate accurate P&L
return realized, totalFee, execPrice, nil
}
func (acc *BacktestAccount) TotalEquity(priceMap map[string]float64) (float64, float64, map[string]float64) {
@@ -243,6 +255,7 @@ func (acc *BacktestAccount) RestoreFromSnapshots(cash float64, realized float64,
Notional: snap.Quantity * snap.AvgPrice,
LiquidationPrice: snap.LiquidationPrice,
OpenTime: snap.OpenTime,
AccumulatedFee: snap.AccumulatedFee,
}
key := positionKey(pos.Symbol, pos.Side)
acc.positions[key] = pos

View File

@@ -29,6 +29,7 @@ type BacktestConfig struct {
RunID string `json:"run_id"`
UserID string `json:"user_id,omitempty"`
AIModelID string `json:"ai_model_id,omitempty"`
StrategyID string `json:"strategy_id,omitempty"` // Optional: use saved strategy from Strategy Studio
Symbols []string `json:"symbols"`
Timeframes []string `json:"timeframes"`
DecisionTimeframe string `json:"decision_timeframe"`
@@ -53,6 +54,9 @@ type BacktestConfig struct {
CheckpointIntervalBars int `json:"checkpoint_interval_bars,omitempty"`
CheckpointIntervalSeconds int `json:"checkpoint_interval_seconds,omitempty"`
ReplayDecisionDir string `json:"replay_decision_dir,omitempty"`
// Internal: loaded strategy config (set by Manager when StrategyID is provided)
loadedStrategy *store.StrategyConfig `json:"-"`
}
// Validate performs validity checks on the configuration and fills in default values.
@@ -178,10 +182,54 @@ func validateFillPolicy(policy string) error {
}
}
// SetLoadedStrategy sets the loaded strategy config from database.
func (cfg *BacktestConfig) SetLoadedStrategy(strategy *store.StrategyConfig) {
cfg.loadedStrategy = strategy
}
// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation.
// This ensures backtest uses the same StrategyEngine logic as live trading.
// If a strategy was loaded from database (via StrategyID), it will be used with overrides.
func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
// Determine primary and longer timeframe from the timeframes list
// If a strategy was loaded from database, use it with some overrides
if cfg.loadedStrategy != nil {
result := *cfg.loadedStrategy // Make a copy
// Override coin source with backtest symbols (回测指定的币对优先)
if len(cfg.Symbols) > 0 {
result.CoinSource.SourceType = "static"
result.CoinSource.StaticCoins = cfg.Symbols
result.CoinSource.UseCoinPool = false
result.CoinSource.UseOITop = false
}
// Override timeframes with backtest config
if len(cfg.Timeframes) > 0 {
result.Indicators.Klines.SelectedTimeframes = cfg.Timeframes
result.Indicators.Klines.PrimaryTimeframe = cfg.Timeframes[0]
if len(cfg.Timeframes) > 1 {
result.Indicators.Klines.LongerTimeframe = cfg.Timeframes[len(cfg.Timeframes)-1]
}
result.Indicators.Klines.EnableMultiTimeframe = len(cfg.Timeframes) > 1
}
// Override leverage with backtest config
if cfg.Leverage.BTCETHLeverage > 0 {
result.RiskControl.BTCETHMaxLeverage = cfg.Leverage.BTCETHLeverage
}
if cfg.Leverage.AltcoinLeverage > 0 {
result.RiskControl.AltcoinMaxLeverage = cfg.Leverage.AltcoinLeverage
}
// Override custom prompt if provided in backtest config
if cfg.CustomPrompt != "" {
result.CustomPrompt = cfg.CustomPrompt
}
return &result
}
// Fallback: build strategy config from backtest config (original logic)
primaryTF := "5m"
longerTF := "4h"
if len(cfg.Timeframes) > 0 {

View File

@@ -491,9 +491,14 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
positions := r.convertPositions(priceMap)
candidateCoins := make([]decision.CandidateCoin, 0, len(r.cfg.Symbols))
for _, sym := range r.cfg.Symbols {
candidateCoins = append(candidateCoins, decision.CandidateCoin{Symbol: sym})
// Get candidate coins from strategy engine (includes source info)
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
if err != nil {
// Fallback to simple list if strategy engine fails
candidateCoins = make([]decision.CandidateCoin, 0, len(r.cfg.Symbols))
for _, sym := range r.cfg.Symbols {
candidateCoins = append(candidateCoins, decision.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
}
}
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
@@ -512,6 +517,36 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
Timeframes: r.cfg.Timeframes,
}
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
strategyConfig := r.strategyEngine.GetConfig()
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
// Collect symbols to query (candidate coins + position coins)
symbolSet := make(map[string]bool)
for _, sym := range r.cfg.Symbols {
symbolSet[sym] = true
}
for _, pos := range positions {
symbolSet[pos.Symbol] = true
}
symbols := make([]string, 0, len(symbolSet))
for sym := range symbolSet {
symbols = append(symbols, sym)
}
ctx.QuantDataMap = r.strategyEngine.FetchQuantDataBatch(symbols)
if len(ctx.QuantDataMap) > 0 {
logger.Infof("📊 Backtest: fetched quant data for %d symbols", len(ctx.QuantDataMap))
}
}
// Fetch OI ranking data if enabled in strategy (uses current data as approximation)
if strategyConfig.Indicators.EnableOIRanking {
ctx.OIRankingData = r.strategyEngine.FetchOIRankingData()
if ctx.OIRankingData != nil {
logger.Infof("📊 Backtest: OI ranking data ready: %d top, %d low positions",
len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
}
}
record := &store.DecisionRecord{
AccountState: store.AccountSnapshot{
TotalBalance: accountInfo.TotalEquity,
@@ -710,10 +745,31 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
if equity <= 0 {
equity = r.account.InitialBalance()
}
// Get leverage for this symbol
leverage := r.resolveLeverage(dec.Leverage, dec.Symbol)
if leverage <= 0 {
leverage = 5
}
// Calculate available margin (leave some buffer for fees)
availableCash := r.account.Cash()
maxMarginToUse := availableCash * 0.9 // Use max 90% of available cash
maxPositionValue := maxMarginToUse * float64(leverage)
sizeUSD := dec.PositionSizeUSD
if sizeUSD <= 0 {
// Default to 5% of equity, but cap to available margin
sizeUSD = 0.05 * equity
}
// Cap position size to what we can actually afford
if sizeUSD > maxPositionValue {
logger.Infof("📊 Backtest: capping position from %.2f to %.2f (available margin: %.2f, leverage: %dx)",
sizeUSD, maxPositionValue, maxMarginToUse, leverage)
sizeUSD = maxPositionValue
}
qty := sizeUSD / price
if qty < 0 {
qty = 0
@@ -855,6 +911,7 @@ func (r *Runner) updateState(ts int64, equity, unrealized, marginUsed float64, p
LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin,
OpenTime: pos.OpenTime,
AccumulatedFee: pos.AccumulatedFee,
}
}
@@ -1098,6 +1155,49 @@ func (r *Runner) StatusPayload() StatusPayload {
snapshot := r.snapshotState()
progress := progressPercent(snapshot, r.cfg)
// Build position statuses with unrealized P&L
positions := make([]PositionStatus, 0, len(snapshot.Positions))
for _, pos := range snapshot.Positions {
if pos.Quantity <= 0 {
continue
}
// Get mark price from feed if available
markPrice := pos.AvgPrice // fallback to entry price
if r.feed != nil && snapshot.BarTimestamp > 0 {
if md, _, err := r.feed.BuildMarketData(snapshot.BarTimestamp); err == nil {
if data, ok := md[pos.Symbol]; ok {
markPrice = data.CurrentPrice
}
}
}
// Calculate unrealized P&L
var unrealizedPnL float64
if pos.Side == "long" {
unrealizedPnL = (markPrice - pos.AvgPrice) * pos.Quantity
} else {
unrealizedPnL = (pos.AvgPrice - markPrice) * pos.Quantity
}
// Calculate P&L percentage based on margin
pnlPct := 0.0
if pos.MarginUsed > 0 {
pnlPct = (unrealizedPnL / pos.MarginUsed) * 100
}
positions = append(positions, PositionStatus{
Symbol: pos.Symbol,
Side: pos.Side,
Quantity: pos.Quantity,
EntryPrice: pos.AvgPrice,
MarkPrice: markPrice,
Leverage: pos.Leverage,
UnrealizedPnL: unrealizedPnL,
UnrealizedPnLPct: pnlPct,
MarginUsed: pos.MarginUsed,
})
}
payload := StatusPayload{
RunID: r.cfg.RunID,
State: r.Status(),
@@ -1108,6 +1208,7 @@ func (r *Runner) StatusPayload() StatusPayload {
Equity: snapshot.Equity,
UnrealizedPnL: snapshot.UnrealizedPnL,
RealizedPnL: snapshot.RealizedPnL,
Positions: positions,
Note: snapshot.LiquidationNote,
LastError: r.lastErrorString(),
LastUpdatedIso: snapshot.LastUpdate.UTC().Format(time.RFC3339),

View File

@@ -25,6 +25,7 @@ type PositionSnapshot struct {
LiquidationPrice float64 `json:"liquidation_price"`
MarginUsed float64 `json:"margin_used"`
OpenTime int64 `json:"open_time"`
AccumulatedFee float64 `json:"accumulated_fee,omitempty"` // Opening fees accumulated
}
// BacktestState represents the real-time state during execution (in-memory state).
@@ -149,16 +150,30 @@ type RunSummary struct {
// StatusPayload is used for /status API responses.
type StatusPayload struct {
RunID string `json:"run_id"`
State RunState `json:"state"`
ProgressPct float64 `json:"progress_pct"`
ProcessedBars int `json:"processed_bars"`
CurrentTime int64 `json:"current_time"`
DecisionCycle int `json:"decision_cycle"`
Equity float64 `json:"equity"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
RealizedPnL float64 `json:"realized_pnl"`
Note string `json:"note,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdatedIso string `json:"last_updated_iso"`
RunID string `json:"run_id"`
State RunState `json:"state"`
ProgressPct float64 `json:"progress_pct"`
ProcessedBars int `json:"processed_bars"`
CurrentTime int64 `json:"current_time"`
DecisionCycle int `json:"decision_cycle"`
Equity float64 `json:"equity"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
RealizedPnL float64 `json:"realized_pnl"`
Positions []PositionStatus `json:"positions,omitempty"`
Note string `json:"note,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdatedIso string `json:"last_updated_iso"`
}
// PositionStatus represents a position with unrealized P&L for status display.
type PositionStatus struct {
Symbol string `json:"symbol"`
Side string `json:"side"`
Quantity float64 `json:"quantity"`
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
Leverage int `json:"leverage"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
MarginUsed float64 `json:"margin_used"`
}