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

@@ -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),