feat: simplify Claw402 autopilot trading flow

This commit is contained in:
tinkle-community
2026-06-27 00:37:59 +08:00
parent 961e016d33
commit 24f6421a73
151 changed files with 7453 additions and 41117 deletions

View File

@@ -52,9 +52,10 @@ func (at *AutoTrader) logErrorf(format string, args ...interface{}) {
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
type AutoTraderConfig struct {
// Trader identification
ID string // Trader unique identifier (for log directory, etc.)
Name string // Trader display name
AIModel string // AI model: "qwen" or "deepseek"
ID string // Trader unique identifier (for log directory, etc.)
Name string // Trader display name
StrategyID string // Associated strategy ID used to refresh live strategy config
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
@@ -121,7 +122,7 @@ type AutoTraderConfig struct {
Claw402WalletKey string
// Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
ScanInterval time.Duration // Scan interval (recommended 15 minutes)
// Account configuration
InitialBalance float64 // Initial balance (for P&L calculation, must be set manually)
@@ -138,7 +139,8 @@ type AutoTraderConfig struct {
ShowInCompetition bool // Whether to show in competition page
// Strategy configuration (use complete strategy config)
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
StrategyConfigRaw string // Raw strategy config JSON from DB, used to detect live edits
}
// AutoTrader automatic trader
@@ -396,6 +398,38 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
}, nil
}
func (at *AutoTrader) reloadStrategyConfigIfChanged() error {
if at == nil || at.store == nil || at.config.StrategyID == "" {
return nil
}
strategy, err := at.store.Strategy().Get(at.userID, at.config.StrategyID)
if err != nil {
return fmt.Errorf("failed to load strategy %s: %w", at.config.StrategyID, err)
}
if at.strategyEngine != nil && strategy.Config == at.config.StrategyConfigRaw {
return nil
}
strategyConfig, err := strategy.ParseConfig()
if err != nil {
return fmt.Errorf("failed to parse strategy %s: %w", strategy.Name, err)
}
strategyConfig.ClampLimits()
claw402Key := at.config.Claw402WalletKey
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
claw402Key = at.config.CustomAPIKey
}
at.config.StrategyConfig = strategyConfig
at.config.StrategyConfigRaw = strategy.Config
at.strategyEngine = kernel.NewStrategyEngine(strategyConfig, claw402Key)
at.logInfof("🔄 Strategy config refreshed from DB: %s", strategy.Name)
return nil
}
// Run runs the automatic trading main loop
func (at *AutoTrader) Run() error {
at.isRunningMutex.Lock()

View File

@@ -0,0 +1,54 @@
package trader
import (
"nofx/kernel"
"nofx/store"
"testing"
)
func TestApplyAutopilotFullSizeOpenForClaw402(t *testing.T) {
cfg := store.GetDefaultStrategyConfig("en")
cfg.CoinSource.SourceType = "vergex_signal"
cfg.RiskControl.BTCETHMaxLeverage = 10
cfg.RiskControl.AltcoinMaxLeverage = 10
cfg.RiskControl.BTCETHMaxPositionValueRatio = 1
cfg.RiskControl.AltcoinMaxPositionValueRatio = 1
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
decision := &kernel.Decision{
Symbol: "xyz:INTC",
Action: "open_long",
Leverage: 3,
PositionSizeUSD: 12,
}
at.applyAutopilotFullSizeOpen(decision, 29.8)
if decision.Leverage != 10 {
t.Fatalf("expected leverage to be forced to 10x, got %dx", decision.Leverage)
}
if decision.PositionSizeUSD != 29.8 {
t.Fatalf("expected position size to use full notional 29.8, got %.2f", decision.PositionSizeUSD)
}
}
func TestApplyAutopilotFullSizeOpenSkipsNonClaw402Strategies(t *testing.T) {
cfg := store.GetDefaultStrategyConfig("en")
cfg.CoinSource.SourceType = "static"
cfg.RiskControl.BTCETHMaxLeverage = 10
cfg.RiskControl.AltcoinMaxLeverage = 10
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
decision := &kernel.Decision{
Symbol: "BTCUSDT",
Action: "open_long",
Leverage: 3,
PositionSizeUSD: 12,
}
at.applyAutopilotFullSizeOpen(decision, 29.8)
if decision.Leverage != 3 || decision.PositionSizeUSD != 12 {
t.Fatalf("non-Claw402 strategies should not be rewritten, got leverage=%d size=%.2f", decision.Leverage, decision.PositionSizeUSD)
}
}

View File

@@ -30,6 +30,10 @@ func (at *AutoTrader) runCycle() error {
return nil
}
if err := at.reloadStrategyConfigIfChanged(); err != nil {
at.logWarnf("⚠️ Strategy refresh failed, using current in-memory config: %v", err)
}
// Check USDC balance periodically for claw402 users (every 10 cycles)
if at.callCount%10 == 0 && store.IsClaw402Config(at.config.AIModel) {
at.checkClaw402Balance()
@@ -250,7 +254,9 @@ func (at *AutoTrader) runCycle() error {
}
}
// Execute decisions and record results
// Execute decisions and record results. Trade throttle is applied here,
// immediately before order placement, so AI churn cannot become live orders.
opensAllowedThisCycle := 0
for _, d := range sortedDecisions {
// Check if trader is stopped before each decision (allow immediate stop during execution)
at.isRunningMutex.RLock()
@@ -275,6 +281,17 @@ func (at *AutoTrader) runCycle() error {
Success: false,
}
if reason := at.tradeThrottleReason(d, ctx, opensAllowedThisCycle); reason != "" {
at.logWarnf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason)
actionRecord.Error = reason
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason))
record.Decisions = append(record.Decisions, actionRecord)
continue
}
if isOpenAction(d.Action) {
opensAllowedThisCycle++
}
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
at.logErrorf("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
actionRecord.Error = err.Error()
@@ -740,7 +757,7 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
func (at *AutoTrader) checkClaw402Balance() {
scanMinutes := int(at.config.ScanInterval.Minutes())
if scanMinutes <= 0 {
scanMinutes = 3
scanMinutes = 15
}
dailyCost, _ := store.EstimateRunway(1.0, at.config.CustomModelName, scanMinutes)
logger.Infof("💰 [%s] Estimated daily AI cost: ~$%.2f (model: %s, interval: %dm)",

View File

@@ -90,6 +90,8 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
equity = availableBalance // Fallback to available balance
}
at.applyAutopilotFullSizeOpen(decision, equity)
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
if wasCapped {
@@ -204,6 +206,8 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
equity = availableBalance // Fallback to available balance
}
at.applyAutopilotFullSizeOpen(decision, equity)
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
if wasCapped {

View File

@@ -2,8 +2,10 @@ package trader
import (
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"strings"
"time"
)
@@ -237,6 +239,46 @@ func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity
return positionSizeUSD, false
}
func (at *AutoTrader) applyAutopilotFullSizeOpen(decision *kernel.Decision, equity float64) {
if at == nil || decision == nil || at.config.StrategyConfig == nil || equity <= 0 {
return
}
cfg := at.config.StrategyConfig
if cfg.CoinSource.SourceType != "vergex_signal" {
return
}
riskControl := cfg.RiskControl
leverage := riskControl.AltcoinMaxLeverage
positionValueRatio := riskControl.AltcoinMaxPositionValueRatio
if isMajorAsset(decision.Symbol) {
leverage = riskControl.BTCETHMaxLeverage
positionValueRatio = riskControl.BTCETHMaxPositionValueRatio
}
if leverage < store.MinLeverage {
leverage = store.MinLeverage
}
if leverage > store.MaxAltLeverage {
leverage = store.MaxAltLeverage
}
if positionValueRatio <= 0 {
positionValueRatio = 1.0
}
fullPositionSize := equity * positionValueRatio
if fullPositionSize <= 0 {
return
}
if decision.Leverage != leverage || decision.PositionSizeUSD != fullPositionSize {
logger.Infof(" 📏 [AUTOPILOT] Full-size open enforced for %s: leverage %dx → %dx, notional %.2f → %.2f USDT",
decision.Symbol, decision.Leverage, leverage, decision.PositionSizeUSD, fullPositionSize)
}
decision.Leverage = leverage
decision.PositionSizeUSD = fullPositionSize
}
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
if at.config.StrategyConfig == nil {

View File

@@ -0,0 +1,278 @@
package trader
import (
"fmt"
"nofx/kernel"
"nofx/market"
"nofx/store"
"strings"
"time"
)
const (
autopilotMinHoldDuration = 45 * time.Minute
autopilotNoiseCloseHoldDuration = 90 * time.Minute
autopilotReentryCooldown = 90 * time.Minute
autopilotMaxOpensPerHour = 1
autopilotMaxOpensPerCycle = 1
earlyCloseStopLossBypassPct = -2.5
earlyCloseTakeProfitBypassPct = 5.0
noiseCloseLossFloorPct = -1.0
noiseCloseProfitCeilingPct = 2.0
)
func isOpenAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "open_long", "open_short":
return true
default:
return false
}
}
func isCloseAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "close_long", "close_short":
return true
default:
return false
}
}
func closeActionSide(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "close_long":
return "long"
case "close_short":
return "short"
default:
return ""
}
}
func openActionSide(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "open_long":
return "long"
case "open_short":
return "short"
default:
return ""
}
}
func normalizedDecisionSymbol(symbol string) string {
return market.Normalize(strings.TrimSpace(symbol))
}
func (at *AutoTrader) tradeThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string {
if ctx == nil {
return ""
}
switch {
case isOpenAction(decision.Action):
return at.openThrottleReason(decision, ctx, opensQueuedThisCycle)
case isCloseAction(decision.Action):
return at.closeThrottleReason(decision, ctx)
default:
return ""
}
}
func (at *AutoTrader) openThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string {
symbol := normalizedDecisionSymbol(decision.Symbol)
if symbol == "" {
return ""
}
if opensQueuedThisCycle >= autopilotMaxOpensPerCycle {
return fmt.Sprintf("trade throttle: only %d new position may be opened per cycle", autopilotMaxOpensPerCycle)
}
if pos := findAnyContextPosition(ctx, symbol); pos != nil {
return fmt.Sprintf("trade throttle: %s already has an open %s position; manage or close it before opening another side", symbol, pos.Side)
}
openCount, err := at.countRecentOpenOrders(time.Now().Add(-1 * time.Hour))
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err)
} else if openCount >= autopilotMaxOpensPerHour {
return fmt.Sprintf("trade throttle: %d open order already executed in the last hour; max is %d", openCount, autopilotMaxOpensPerHour)
}
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-autopilotReentryCooldown)); order != nil {
age := time.Since(time.UnixMilli(order.CreatedAt))
remaining := autopilotReentryCooldown - age
if remaining < 0 {
remaining = 0
}
return fmt.Sprintf("trade throttle: %s was closed %s ago; wait %s before re-entry", symbol, roundDuration(age), roundDuration(remaining))
}
return ""
}
func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.Context) string {
symbol := normalizedDecisionSymbol(decision.Symbol)
side := closeActionSide(decision.Action)
if symbol == "" || side == "" {
return ""
}
pos := findContextPosition(ctx, symbol, side)
pnlPct := 0.0
entryTime := int64(0)
if pos != nil {
pnlPct = pos.UnrealizedPnLPct
entryTime = pos.UpdateTime
}
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-autopilotNoiseCloseHoldDuration)); order != nil && order.CreatedAt > entryTime {
entryTime = order.CreatedAt
}
if entryTime <= 0 {
return ""
}
heldFor := time.Since(time.UnixMilli(entryTime))
if heldFor < 0 {
heldFor = 0
}
if heldFor >= autopilotMinHoldDuration {
if heldFor >= autopilotNoiseCloseHoldDuration ||
pnlPct <= noiseCloseLossFloorPct ||
pnlPct >= noiseCloseProfitCeilingPct {
return ""
}
remaining := autopilotNoiseCloseHoldDuration - heldFor
return fmt.Sprintf(
"trade throttle: %s %s has been held for %s with PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close",
symbol,
side,
roundDuration(heldFor),
pnlPct,
noiseCloseLossFloorPct,
noiseCloseProfitCeilingPct,
roundDuration(remaining),
)
}
// Do not block true risk exits or unusually strong take-profit exits.
if pnlPct <= earlyCloseStopLossBypassPct || pnlPct >= earlyCloseTakeProfitBypassPct {
return ""
}
remaining := autopilotMinHoldDuration - heldFor
return fmt.Sprintf(
"trade throttle: %s %s has only been held for %s with PnL %.2f%%; min AI-managed hold is %s unless loss <= %.1f%% or profit >= %.1f%%",
symbol,
side,
roundDuration(heldFor),
pnlPct,
roundDuration(autopilotMinHoldDuration),
earlyCloseStopLossBypassPct,
earlyCloseTakeProfitBypassPct,
) + fmt.Sprintf("; wait about %s", roundDuration(remaining))
}
func findContextPosition(ctx *kernel.Context, symbol string, side string) *kernel.PositionInfo {
if ctx == nil {
return nil
}
for i := range ctx.Positions {
pos := &ctx.Positions[i]
if normalizedDecisionSymbol(pos.Symbol) == symbol && strings.EqualFold(pos.Side, side) {
return pos
}
}
return nil
}
func findAnyContextPosition(ctx *kernel.Context, symbol string) *kernel.PositionInfo {
if ctx == nil {
return nil
}
for i := range ctx.Positions {
pos := &ctx.Positions[i]
if normalizedDecisionSymbol(pos.Symbol) == symbol {
return pos
}
}
return nil
}
func (at *AutoTrader) recentOrders(limit int) ([]*store.TraderOrder, error) {
if at == nil || at.store == nil {
return nil, nil
}
return at.store.Order().GetTraderOrders(at.id, limit)
}
func (at *AutoTrader) countRecentOpenOrders(since time.Time) (int, error) {
orders, err := at.recentOrders(100)
if err != nil {
return 0, err
}
sinceMs := since.UTC().UnixMilli()
count := 0
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if isOpenAction(order.OrderAction) {
count++
}
}
return count, nil
}
func (at *AutoTrader) findRecentCloseOrder(symbol string, since time.Time) *store.TraderOrder {
orders, err := at.recentOrders(100)
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent close orders: %v", err)
return nil
}
sinceMs := since.UTC().UnixMilli()
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if normalizedDecisionSymbol(order.Symbol) == symbol && isCloseAction(order.OrderAction) {
return order
}
}
return nil
}
func (at *AutoTrader) findRecentOpenOrder(symbol string, side string, since time.Time) *store.TraderOrder {
orders, err := at.recentOrders(100)
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err)
return nil
}
sinceMs := since.UTC().UnixMilli()
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if normalizedDecisionSymbol(order.Symbol) == symbol &&
strings.EqualFold(openActionSide(order.OrderAction), side) {
return order
}
}
return nil
}
func isCanceledOrder(order *store.TraderOrder) bool {
status := strings.ToUpper(strings.TrimSpace(order.Status))
return status == "CANCELED" || status == "CANCELLED" || status == "REJECTED" || status == "EXPIRED"
}
func roundDuration(d time.Duration) string {
if d < time.Minute {
return "0m"
}
return d.Round(time.Minute).String()
}

View File

@@ -0,0 +1,81 @@
package trader
import (
"nofx/kernel"
"strings"
"testing"
"time"
)
func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context {
return &kernel.Context{
Positions: []kernel.PositionInfo{
{
Symbol: symbol,
Side: side,
UnrealizedPnLPct: pnlPct,
UpdateTime: time.Now().Add(-heldFor).UnixMilli(),
},
},
}
}
func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -0.3)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if !strings.Contains(reason, "min AI-managed hold") {
t.Fatalf("expected early close to be blocked by min hold, got %q", reason)
}
}
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if reason != "" {
t.Fatalf("expected hard stop close to pass, got %q", reason)
}
}
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if !strings.Contains(reason, "noise band") {
t.Fatalf("expected flat close to be blocked inside noise window, got %q", reason)
}
}
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if reason != "" {
t.Fatalf("expected confirmed loss after min hold to pass, got %q", reason)
}
}
func TestTradeThrottleBlocksSecondOpenInCycle(t *testing.T) {
at := &AutoTrader{}
ctx := &kernel.Context{}
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1)
if !strings.Contains(reason, "only 1 new position") {
t.Fatalf("expected second open in cycle to be blocked, got %q", reason)
}
}
func TestTradeThrottleBlocksOpeningAgainstExistingPosition(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 1.0)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 0)
if !strings.Contains(reason, "already has an open") {
t.Fatalf("expected opposite open to be blocked when position exists, got %q", reason)
}
}