Files
nofx/backtest/runner_orders.go
tinkle-community cb31782be4 refactor: split large files and clean up project structure
- Rename experience/ to telemetry/ for clarity
- Split 15+ large Go files (800-2200 lines) into focused modules:
  kernel/engine.go, backtest/runner.go, market/data.go, store/position.go,
  api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders
- Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx
  into domain-specific modules with barrel re-exports
- Remove stale files: screenshots, .yml.old, pyproject.toml
- Remove unused scripts/ and cmd/ directories
- Remove broken/outdated test files (network-dependent, stale expectations)
2026-03-12 12:53:57 +08:00

421 lines
11 KiB
Go

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
}