mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 08:46:58 +08:00
feat: simplify Claw402 autopilot trading flow
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/agent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type agentPreferencePayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
|
||||
return
|
||||
}
|
||||
|
||||
var prefs []agent.PersistentPreference
|
||||
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
|
||||
var req agentPreferencePayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
|
||||
return
|
||||
}
|
||||
if len([]rune(strings.TrimSpace(req.Text))) > 500 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text too long"})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := agent.NewPersistentPreference(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prefs := s.loadAgentPreferences(uid)
|
||||
prefs = append([]agent.PersistentPreference{created}, prefs...)
|
||||
if len(prefs) > 20 {
|
||||
prefs = prefs[:20]
|
||||
}
|
||||
|
||||
if err := s.saveAgentPreferences(uid, prefs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
|
||||
prefs := s.loadAgentPreferences(uid)
|
||||
filtered := prefs[:0]
|
||||
for _, pref := range prefs {
|
||||
if pref.ID != id {
|
||||
filtered = append(filtered, pref)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.saveAgentPreferences(uid, filtered); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
|
||||
}
|
||||
|
||||
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
|
||||
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return []agent.PersistentPreference{}
|
||||
}
|
||||
|
||||
var prefs []agent.PersistentPreference
|
||||
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
|
||||
return []agent.PersistentPreference{}
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
|
||||
data, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nofx/agent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
|
||||
// Chat endpoint requires authentication; market data endpoints are public.
|
||||
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
|
||||
// Chat requires auth — can trigger trades and access account data
|
||||
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
|
||||
isAdmin := c.GetString("user_id") == "admin"
|
||||
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
|
||||
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
|
||||
Authenticated: true,
|
||||
IsAdmin: isAdmin,
|
||||
CanExecuteTrade: true,
|
||||
CanViewSensitiveSecrets: false,
|
||||
})
|
||||
req := c.Request.WithContext(ctx)
|
||||
h.HandleChat(c.Writer, req)
|
||||
})
|
||||
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
|
||||
isAdmin := c.GetString("user_id") == "admin"
|
||||
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
|
||||
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
|
||||
Authenticated: true,
|
||||
IsAdmin: isAdmin,
|
||||
CanExecuteTrade: true,
|
||||
CanViewSensitiveSecrets: false,
|
||||
})
|
||||
req := c.Request.WithContext(ctx)
|
||||
h.HandleChatStream(c.Writer, req)
|
||||
})
|
||||
// Public endpoints — read-only market data
|
||||
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
|
||||
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
|
||||
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
|
||||
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nofx/agent"
|
||||
)
|
||||
|
||||
// handleAI500List serves the AI500 index board for the agent UI panel.
|
||||
// Data is fetched through the user's claw402 wallet when one is configured
|
||||
// (falling back to the direct nofxos client) and served from a 5-minute
|
||||
// cache, so panel polling never hammers the upstream.
|
||||
func (s *Server) handleAI500List(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
limit := 0
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a non-negative integer"})
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
|
||||
walletKey := agent.Claw402WalletKeyForStoreUser(s.store, userID)
|
||||
entries, err := agent.AI500Board(walletKey, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get AI500 list", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Flat body, matching /api/symbols: the web httpClient wraps the raw
|
||||
// response body as `data`, so a nested success/data envelope here would
|
||||
// hide the coins from the panel.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"coins": entries,
|
||||
"count": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type SafeExchangeConfig struct {
|
||||
HasPassphrase bool `json:"has_passphrase"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
@@ -59,6 +60,7 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||
HasPassphrase: exchange.Passphrase != "",
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
|
||||
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
||||
AsterUser: exchange.AsterUser,
|
||||
@@ -80,7 +82,7 @@ type ExchangeConfigUpdate struct {
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
@@ -105,7 +107,7 @@ type CreateExchangeRequest struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
@@ -147,6 +149,19 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, safeExchanges)
|
||||
}
|
||||
|
||||
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
if strings.EqualFold(exchangeType, "hyperliquid") {
|
||||
if len(fallback) > 0 {
|
||||
return fallback[0]
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -255,6 +270,11 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
if exchangeData.HyperliquidBuilderApproved != nil {
|
||||
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
||||
}
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
|
||||
existing.ExchangeType,
|
||||
exchangeData.HyperliquidUnifiedAcct,
|
||||
existing.HyperliquidUnifiedAcct,
|
||||
)
|
||||
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
existing.ExchangeType,
|
||||
@@ -281,7 +301,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
@@ -387,10 +407,11 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Exchange configs only persist once complete; persisted configs are always enabled.
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, true,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
||||
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T
|
||||
APIKey: crypto.EncryptedString("api-test-123"),
|
||||
SecretKey: crypto.EncryptedString("secret-test-123"),
|
||||
Passphrase: crypto.EncryptedString("passphrase-test-123"),
|
||||
HyperliquidUnifiedAcct: true,
|
||||
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
|
||||
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
|
||||
@@ -42,4 +43,24 @@ func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T
|
||||
if !safe.HasLighterAPIKey {
|
||||
t.Fatalf("expected has_lighter_api_key_private_key to be true")
|
||||
}
|
||||
if !safe.HyperliquidUnifiedAcct {
|
||||
t.Fatalf("expected hyperliquid unified account to be exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
|
||||
t.Fatalf("expected new hyperliquid accounts to default unified account on")
|
||||
}
|
||||
if effectiveHyperliquidUnifiedAccount("binance", nil) {
|
||||
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
|
||||
}
|
||||
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
|
||||
if fallbackFalse {
|
||||
t.Fatalf("expected omitted update field to preserve existing false value")
|
||||
}
|
||||
requestedTrue := true
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
|
||||
t.Fatalf("expected explicit true to override existing false value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval default value
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 15
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
|
||||
}
|
||||
|
||||
// Query exchange actual balance, override user input
|
||||
|
||||
@@ -225,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
trend, megaCap, breakout strategyI18n
|
||||
defaultStrategy strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
trend: strategyI18n{"美股趋势策略", "开箱即用的 Hyperliquid 美股 USDC 策略。只扫描流动性更好的美股合约,低杠杆、低频率,适合直接创建 Agent 后运行。"},
|
||||
megaCap: strategyI18n{"美股大盘稳健策略", "开箱即用的 Hyperliquid 美股 USDC 策略。固定关注 AAPL、MSFT、GOOGL、AMZN、META 等大盘股,强调趋势确认和回撤控制。"},
|
||||
breakout: strategyI18n{"美股突破策略", "开箱即用的 Hyperliquid 美股 USDC 策略。扫描 24h 强势美股,等待突破确认后再开仓,避免频繁追涨。"},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"},
|
||||
},
|
||||
"en": {
|
||||
trend: strategyI18n{"US Stock Trend Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans liquid US stock perps with low leverage and low trade frequency, suitable for one-click Agent deployment."},
|
||||
megaCap: strategyI18n{"US Mega-Cap Steady Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Fixed universe: AAPL, MSFT, GOOGL, AMZN and META, with trend confirmation and drawdown control."},
|
||||
breakout: strategyI18n{"US Stock Breakout Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans 24h strong US stocks and waits for breakout confirmation before entering, avoiding impulsive chasing."},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"id": {
|
||||
trend: strategyI18n{"Strategi Tren Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai perp saham AS likuid dengan leverage rendah dan frekuensi rendah."},
|
||||
megaCap: strategyI18n{"Strategi Stabil Mega-Cap AS", "Strategi saham AS USDC Hyperliquid siap jalan. Universe tetap: AAPL, MSFT, GOOGL, AMZN, META, dengan konfirmasi tren."},
|
||||
breakout: strategyI18n{"Strategi Breakout Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai saham AS kuat 24 jam dan menunggu konfirmasi breakout."},
|
||||
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
@@ -256,76 +250,42 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
applyConfig func(*store.StrategyConfig)
|
||||
}
|
||||
|
||||
setStockRank := func(c *store.StrategyConfig, direction string, limit int) {
|
||||
c.CoinSource.SourceType = "hyper_rank"
|
||||
setClaw402Strategy := func(c *store.StrategyConfig) {
|
||||
c.CoinSource.SourceType = "vergex_signal"
|
||||
c.CoinSource.StaticCoins = nil
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
c.CoinSource.HyperRankCategory = "stock"
|
||||
c.CoinSource.HyperRankDirection = direction
|
||||
c.CoinSource.HyperRankLimit = limit
|
||||
}
|
||||
setStaticStocks := func(c *store.StrategyConfig, symbols []string) {
|
||||
c.CoinSource.SourceType = "static"
|
||||
c.CoinSource.StaticCoins = symbols
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
}
|
||||
setStableRisk := func(c *store.StrategyConfig) {
|
||||
c.CoinSource.HyperRankCategory = "all"
|
||||
c.CoinSource.VergexLimit = 10
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
c.RiskControl.MaxPositions = 2
|
||||
c.RiskControl.BTCETHMaxLeverage = 3
|
||||
c.RiskControl.AltcoinMaxLeverage = 3
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 2.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 0.6
|
||||
c.RiskControl.MaxMarginUsage = 0.45
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 10
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 1.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 1.0
|
||||
c.RiskControl.MaxMarginUsage = 0.35
|
||||
c.RiskControl.MinConfidence = 78
|
||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
c.Indicators.Klines.LongerTimeframe = "4h"
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
|
||||
c.Indicators.EnableEMA = true
|
||||
c.Indicators.EnableMACD = true
|
||||
c.Indicators.EnableRSI = true
|
||||
c.Indicators.EnableATR = true
|
||||
c.Indicators.EnableVolume = true
|
||||
c.Indicators.Klines.PrimaryCount = 30
|
||||
c.Indicators.Klines.LongerTimeframe = ""
|
||||
c.Indicators.Klines.LongerCount = 0
|
||||
c.Indicators.Klines.EnableMultiTimeframe = false
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
|
||||
c.Indicators.EnableRawKlines = true
|
||||
}
|
||||
|
||||
definitions := []strategyDef{
|
||||
{
|
||||
name: locale.trend.name,
|
||||
description: locale.trend.description,
|
||||
name: locale.defaultStrategy.name,
|
||||
description: locale.defaultStrategy.description,
|
||||
isActive: true,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
setStockRank(c, "volume", 5)
|
||||
setStableRisk(c)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.megaCap.name,
|
||||
description: locale.megaCap.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
setStaticStocks(c, []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"})
|
||||
setStableRisk(c)
|
||||
c.RiskControl.MaxPositions = 2
|
||||
c.RiskControl.MinConfidence = 80
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.breakout.name,
|
||||
description: locale.breakout.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
setStockRank(c, "gainers", 5)
|
||||
setStableRisk(c)
|
||||
c.RiskControl.MinConfidence = 82
|
||||
c.RiskControl.MinRiskRewardRatio = 3.5
|
||||
setClaw402Strategy(c)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -359,8 +319,11 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"均衡策略", "稳健策略", "积极策略",
|
||||
"美股趋势策略", "美股稳健策略", "美股突破策略",
|
||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
||||
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
|
||||
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
@@ -24,8 +24,8 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
if len(strategies) != 3 {
|
||||
t.Fatalf("expected 3 default strategies, got %d", len(strategies))
|
||||
if len(strategies) != 1 {
|
||||
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
|
||||
}
|
||||
|
||||
byName := map[string]*store.Strategy{}
|
||||
@@ -43,40 +43,22 @@ func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
|
||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
||||
}
|
||||
|
||||
trend := byName["美股趋势策略"]
|
||||
if trend == nil || !trend.IsActive {
|
||||
t.Fatalf("美股趋势策略 should exist and be active")
|
||||
defaultStrategy := byName["NOFX Claw402 自动策略"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 自动策略 should exist and be active")
|
||||
}
|
||||
trendCfg, err := trend.ParseConfig()
|
||||
trendCfg, err := defaultStrategy.ParseConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("trend ParseConfig failed: %v", err)
|
||||
t.Fatalf("default ParseConfig failed: %v", err)
|
||||
}
|
||||
if trendCfg.CoinSource.SourceType != "hyper_rank" || trendCfg.CoinSource.HyperRankCategory != "stock" || trendCfg.CoinSource.HyperRankDirection != "volume" {
|
||||
t.Fatalf("trend strategy should use Hyperliquid stock volume ranking, got %+v", trendCfg.CoinSource)
|
||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
||||
}
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 || trendCfg.RiskControl.MaxMarginUsage > 0.45 {
|
||||
t.Fatalf("trend strategy should be low-risk Hyperliquid native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
t.Fatalf("default strategy should be low-risk Claw402/Vergex native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
|
||||
megaCap := byName["美股大盘稳健策略"]
|
||||
if megaCap == nil {
|
||||
t.Fatalf("美股大盘稳健策略 should exist")
|
||||
}
|
||||
megaCfg, err := megaCap.ParseConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("megaCap ParseConfig failed: %v", err)
|
||||
}
|
||||
if megaCfg.CoinSource.SourceType != "static" {
|
||||
t.Fatalf("mega-cap strategy should use static stock symbols, got %+v", megaCfg.CoinSource)
|
||||
}
|
||||
wantSymbols := []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"}
|
||||
if len(megaCfg.CoinSource.StaticCoins) != len(wantSymbols) {
|
||||
t.Fatalf("unexpected static stock list: %+v", megaCfg.CoinSource.StaticCoins)
|
||||
}
|
||||
for i, want := range wantSymbols {
|
||||
if megaCfg.CoinSource.StaticCoins[i] != want {
|
||||
t.Fatalf("static stock %d: want %s got %s", i, want, megaCfg.CoinSource.StaticCoins[i])
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
|
||||
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +122,8 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust
|
||||
if byName["均衡策略"] != 0 {
|
||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
||||
}
|
||||
for _, name := range []string{"美股趋势策略", "美股大盘稳健策略", "美股突破策略"} {
|
||||
if byName[name] != 1 {
|
||||
t.Fatalf("expected exactly one %s, got names=%+v", name, byName)
|
||||
}
|
||||
if byName["NOFX Claw402 自动策略"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 自动策略, got names=%+v", byName)
|
||||
}
|
||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
||||
|
||||
115
api/handler_vergex.go
Normal file
115
api/handler_vergex.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/provider/vergex"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-ranking failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
|
||||
marketType := strings.TrimSpace(c.Query("marketType"))
|
||||
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexSignalLab(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetSignalLab(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-lab failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return nil, false
|
||||
}
|
||||
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
if walletKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
|
||||
return nil, false
|
||||
}
|
||||
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -202,9 +202,6 @@ func (s *Server) setupRoutes() {
|
||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
||||
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
|
||||
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
|
||||
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
|
||||
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
|
||||
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
|
||||
|
||||
// User account management
|
||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||
@@ -214,8 +211,9 @@ func (s *Server) setupRoutes() {
|
||||
// Server IP query (requires authentication, for whitelist configuration)
|
||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||
|
||||
// AI500 index board (cached; routed through claw402 when configured)
|
||||
s.route(protected, "GET", "/ai500", "AI500 index board (?limit=20)", s.handleAI500List)
|
||||
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
|
||||
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
|
||||
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
|
||||
|
||||
// AI trader management
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
@@ -226,7 +224,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
|
||||
`:id = trader_id from GET /api/my-traders`,
|
||||
s.handleGetTraderConfig)
|
||||
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
|
||||
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
|
||||
s.handleCreateTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
|
||||
@@ -337,10 +335,10 @@ CRITICAL: Always use the "id" field for strategy_id.`,
|
||||
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
|
||||
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short)
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
|
||||
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
|
||||
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
|
||||
coin_source.vergex_chain: "hyperliquid"
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -636,6 +637,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
symbols = append(symbols, c.Symbol)
|
||||
}
|
||||
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
||||
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
|
||||
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
@@ -666,6 +668,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
VergexDataMap: vergexDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
|
||||
Reference in New Issue
Block a user