package backtest import ( "fmt" "strings" "time" "nofx/kernel" "nofx/logger" "nofx/market" "nofx/store" ) func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) { symbol := dec.Symbol if symbol == "" { return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision") } usedLeverage := r.resolveLeverage(dec.Leverage, symbol) actionRecord := store.DecisionAction{ Action: dec.Action, Symbol: symbol, Leverage: usedLeverage, Timestamp: time.UnixMilli(ts).UTC(), } if priceMap == nil { return actionRecord, nil, "", fmt.Errorf("priceMap is nil") } basePrice, ok := priceMap[symbol] if !ok || basePrice <= 0 { return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice) } fillPrice := r.executionPrice(symbol, basePrice, ts) switch dec.Action { case "open_long": qty := r.determineQuantity(dec, basePrice) if qty <= 0 { return actionRecord, nil, "", fmt.Errorf("invalid qty") } pos, fee, execPrice, err := r.account.Open(symbol, "long", qty, usedLeverage, fillPrice, ts) if err != nil { return actionRecord, nil, "", err } actionRecord.Quantity = qty actionRecord.Price = execPrice actionRecord.Leverage = pos.Leverage trade := TradeEvent{ Timestamp: ts, Symbol: symbol, Action: dec.Action, Side: "long", Quantity: qty, Price: execPrice, Fee: fee, Slippage: execPrice - basePrice, OrderValue: execPrice * qty, RealizedPnL: 0, Leverage: pos.Leverage, Cycle: cycle, PositionAfter: pos.Quantity, } return actionRecord, []TradeEvent{trade}, "", nil case "open_short": qty := r.determineQuantity(dec, basePrice) if qty <= 0 { return actionRecord, nil, "", fmt.Errorf("invalid qty") } pos, fee, execPrice, err := r.account.Open(symbol, "short", qty, usedLeverage, fillPrice, ts) if err != nil { return actionRecord, nil, "", err } actionRecord.Quantity = qty actionRecord.Price = execPrice actionRecord.Leverage = pos.Leverage trade := TradeEvent{ Timestamp: ts, Symbol: symbol, Action: dec.Action, Side: "short", Quantity: qty, Price: execPrice, Fee: fee, Slippage: basePrice - execPrice, OrderValue: execPrice * qty, RealizedPnL: 0, Leverage: pos.Leverage, Cycle: cycle, PositionAfter: pos.Quantity, } return actionRecord, []TradeEvent{trade}, "", nil case "close_long": qty := r.determineCloseQuantity(symbol, "long", dec) if qty <= 0 { return actionRecord, nil, "", fmt.Errorf("invalid close qty") } posLev := r.account.positionLeverage(symbol, "long") realized, fee, execPrice, err := r.account.Close(symbol, "long", qty, fillPrice) if err != nil { return actionRecord, nil, "", err } actionRecord.Quantity = qty actionRecord.Price = execPrice actionRecord.Leverage = posLev trade := TradeEvent{ Timestamp: ts, Symbol: symbol, Action: dec.Action, Side: "long", Quantity: qty, Price: execPrice, Fee: fee, Slippage: basePrice - execPrice, OrderValue: execPrice * qty, RealizedPnL: realized - fee, Leverage: posLev, Cycle: cycle, PositionAfter: r.remainingPosition(symbol, "long"), } return actionRecord, []TradeEvent{trade}, "", nil case "close_short": qty := r.determineCloseQuantity(symbol, "short", dec) if qty <= 0 { return actionRecord, nil, "", fmt.Errorf("invalid close qty") } posLev := r.account.positionLeverage(symbol, "short") realized, fee, execPrice, err := r.account.Close(symbol, "short", qty, fillPrice) if err != nil { return actionRecord, nil, "", err } actionRecord.Quantity = qty actionRecord.Price = execPrice actionRecord.Leverage = posLev trade := TradeEvent{ Timestamp: ts, Symbol: symbol, Action: dec.Action, Side: "short", Quantity: qty, Price: execPrice, Fee: fee, Slippage: execPrice - basePrice, OrderValue: execPrice * qty, RealizedPnL: realized - fee, Leverage: posLev, Cycle: cycle, PositionAfter: r.remainingPosition(symbol, "short"), } return actionRecord, []TradeEvent{trade}, "", nil case "hold", "wait": return actionRecord, nil, fmt.Sprintf("hold position: %s", dec.Action), nil default: return actionRecord, nil, "", fmt.Errorf("unsupported action %s", dec.Action) } } // MinPositionSizeUSD is the minimum position size in USD to avoid dust positions const MinPositionSizeUSD = 10.0 func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 { snapshot := r.snapshotState() equity := snapshot.Equity 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 } // Reject positions below minimum size to avoid dust positions if sizeUSD < MinPositionSizeUSD { logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)", sizeUSD, MinPositionSizeUSD) return 0 } qty := sizeUSD / price if qty < 0 { qty = 0 } return qty } func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision) float64 { for _, pos := range r.account.Positions() { if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side { return pos.Quantity } } return 0 } func (r *Runner) resolveLeverage(requested int, symbol string) int { sym := strings.ToUpper(symbol) isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT" // Determine configured max leverage for this symbol type var maxLeverage int if isBTCETH { maxLeverage = r.cfg.Leverage.BTCETHLeverage if maxLeverage <= 0 { maxLeverage = 10 // Default max for BTC/ETH } } else { maxLeverage = r.cfg.Leverage.AltcoinLeverage if maxLeverage <= 0 { maxLeverage = 5 // Default max for altcoins } } // Use requested leverage if provided, otherwise use max as default leverage := requested if leverage <= 0 { leverage = maxLeverage } // Enforce max leverage limit if leverage > maxLeverage { logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s", leverage, maxLeverage, symbol) leverage = maxLeverage } return leverage } func (r *Runner) remainingPosition(symbol, side string) float64 { for _, pos := range r.account.Positions() { if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side { return pos.Quantity } } return 0 } func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.PositionSnapshot { positions := r.account.Positions() list := make([]store.PositionSnapshot, 0, len(positions)) for _, pos := range positions { price := priceMap[pos.Symbol] list = append(list, store.PositionSnapshot{ Symbol: pos.Symbol, Side: pos.Side, PositionAmt: pos.Quantity, EntryPrice: pos.EntryPrice, MarkPrice: price, UnrealizedProfit: unrealizedPnL(pos, price), Leverage: float64(pos.Leverage), LiquidationPrice: pos.LiquidationPrice, }) } return list } func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.PositionInfo { positions := r.account.Positions() list := make([]kernel.PositionInfo, 0, len(positions)) for _, pos := range positions { price := priceMap[pos.Symbol] pnl := unrealizedPnL(pos, price) // Calculate P&L percentage based on entry notional (position cost) pnlPct := 0.0 if pos.Notional > 0 { pnlPct = (pnl / pos.Notional) * 100 } list = append(list, kernel.PositionInfo{ Symbol: pos.Symbol, Side: pos.Side, EntryPrice: pos.EntryPrice, MarkPrice: price, Quantity: pos.Quantity, Leverage: pos.Leverage, UnrealizedPnL: pnl, UnrealizedPnLPct: pnlPct, LiquidationPrice: pos.LiquidationPrice, MarginUsed: pos.Margin, UpdateTime: time.Now().UnixMilli(), }) } return list } func (r *Runner) executionPrice(symbol string, markPrice float64, ts int64) float64 { curr, next := r.feed.decisionBarSnapshot(symbol, ts) switch r.cfg.FillPolicy { case FillPolicyNextOpen: if next != nil && next.Open > 0 { return next.Open } case FillPolicyBarVWAP: if curr != nil { if vwap := barVWAP(*curr); vwap > 0 { return vwap } } case FillPolicyMidPrice: if curr != nil && curr.High > 0 && curr.Low > 0 { return (curr.High + curr.Low) / 2 } } return markPrice } func (r *Runner) totalMarginUsed() float64 { sum := 0.0 for _, pos := range r.account.Positions() { sum += pos.Margin } return sum } func (r *Runner) checkLiquidation(ts int64, priceMap map[string]float64, cycle int) ([]TradeEvent, string, error) { positions := append([]*position(nil), r.account.Positions()...) events := make([]TradeEvent, 0) var noteBuilder strings.Builder for _, pos := range positions { price := priceMap[pos.Symbol] liqPrice := pos.LiquidationPrice trigger := false execPrice := price if pos.Side == "long" { if price <= liqPrice && liqPrice > 0 { trigger = true execPrice = liqPrice } } else { if price >= liqPrice && liqPrice > 0 { trigger = true execPrice = liqPrice } } if !trigger { continue } realized, fee, finalPrice, err := r.account.Close(pos.Symbol, pos.Side, pos.Quantity, execPrice) if err != nil { return nil, "", err } noteBuilder.WriteString(fmt.Sprintf("%s %s @ %.4f; ", pos.Symbol, pos.Side, finalPrice)) evt := TradeEvent{ Timestamp: ts, Symbol: pos.Symbol, Action: "liquidated", Side: pos.Side, Quantity: pos.Quantity, Price: finalPrice, Fee: fee, Slippage: 0, OrderValue: finalPrice * pos.Quantity, RealizedPnL: realized - fee, Leverage: pos.Leverage, Cycle: cycle, PositionAfter: 0, LiquidationFlag: true, Note: fmt.Sprintf("forced liquidation at %.4f", finalPrice), } events = append(events, evt) } if len(events) == 0 { return events, "", nil } note := strings.TrimSuffix(noteBuilder.String(), "; ") r.stateMu.Lock() r.state.Liquidated = true r.state.LiquidationNote = note r.stateMu.Unlock() return events, note, nil } func barVWAP(k market.Kline) float64 { values := []float64{k.Open, k.High, k.Low, k.Close} sum := 0.0 count := 0.0 for _, v := range values { if v > 0 { sum += v count++ } } if count == 0 { return 0 } return sum / count }