mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- 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)
421 lines
11 KiB
Go
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
|
|
}
|