mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 21:12:00 +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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user