mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 06:20:58 +08:00
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user