mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 14:56:57 +08:00
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)
This commit is contained in:
420
backtest/runner_orders.go
Normal file
420
backtest/runner_orders.go
Normal file
@@ -0,0 +1,420 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user