feat(trader): wire Hyperliquid wallet and quick trade flow

- Add wallet API endpoints and exchange storage fields for Hyperliquid

- Normalize quick trade order paths, symbols, and builder fee coverage

- Add frontend wallet connect and quick trade helpers
This commit is contained in:
tinklefund
2026-05-25 01:24:58 +08:00
parent f37fc9f887
commit c7c003cc3c
16 changed files with 1731 additions and 253 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"nofx/wallet"
"strings"
@@ -207,6 +208,7 @@ func (at *AutoTrader) runCycle() error {
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
sortedDecisions = at.filterDecisionsToStrategyUniverse(sortedDecisions, ctx)
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
for i, d := range sortedDecisions {
@@ -286,6 +288,52 @@ func (at *AutoTrader) runCycle() error {
return nil
}
func normalizeUniverseSymbol(symbol string) string {
return market.Normalize(strings.TrimSpace(symbol))
}
func isOpenDecision(action string) bool {
a := strings.ToLower(strings.TrimSpace(action))
return a == "open_long" || a == "open_short"
}
func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decision, ctx *kernel.Context) []kernel.Decision {
if ctx == nil || len(decisions) == 0 {
return decisions
}
allowed := make(map[string]bool, len(ctx.CandidateCoins))
for _, coin := range ctx.CandidateCoins {
allowed[normalizeUniverseSymbol(coin.Symbol)] = true
}
positions := make(map[string]bool, len(ctx.Positions))
for _, pos := range ctx.Positions {
positions[normalizeUniverseSymbol(pos.Symbol)] = true
}
filtered := make([]kernel.Decision, 0, len(decisions))
for _, d := range decisions {
sym := normalizeUniverseSymbol(d.Symbol)
if sym == "" || sym == "ALL" {
filtered = append(filtered, d)
continue
}
if allowed[sym] || positions[sym] {
filtered = append(filtered, d)
continue
}
if isOpenDecision(d.Action) {
at.logWarnf("🚫 Blocked AI %s for %s: symbol is outside strategy candidate universe", d.Action, d.Symbol)
} else {
at.logWarnf("🚫 Dropped AI decision for %s: symbol is outside strategy candidate universe", d.Symbol)
}
}
return filtered
}
// buildTradingContext builds trading context
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 1. Get account information

View File

@@ -0,0 +1,15 @@
package hyperliquid
import "testing"
func TestDefaultBuilderIsHardcodedToApprovedFeeTier(t *testing.T) {
if defaultBuilder == nil {
t.Fatal("defaultBuilder is nil")
}
if got := defaultBuilder.Builder; got != "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d" {
t.Fatalf("defaultBuilder.Builder = %s, want hardcoded NOFX builder", got)
}
if got := defaultBuilder.Fee; got != 100 {
t.Fatalf("defaultBuilder.Fee = %d, want hardcoded 100 for 0.1%%", got)
}
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"fmt"
"nofx/logger"
hlprovider "nofx/provider/hyperliquid"
"strconv"
"strings"
"sync"
@@ -58,54 +59,25 @@ var xyzDexAssets = map[string]bool{
"XYZ100": true,
}
// defaultBuilder is the builder info for order routing
// Set to nil to avoid requiring builder fee approval
//
// var defaultBuilder = &hyperliquid.BuilderInfo{
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
// Fee: 10,
// }
var defaultBuilder *hyperliquid.BuilderInfo = nil
// isXyzDexAsset checks if a symbol is an xyz dex asset
func isXyzDexAsset(symbol string) bool {
// Remove common suffixes to get base symbol
base := strings.ToUpper(symbol) // Convert to uppercase for case-insensitive matching
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
// Remove xyz: prefix if present (case-insensitive)
base = strings.TrimPrefix(base, "XYZ:")
base = strings.TrimPrefix(base, "xyz:")
return xyzDexAssets[base]
// defaultBuilder is the builder info for order routing.
// Users approve this builder during the top-right Hyperliquid connect flow before
// their generated agent wallet is saved for live trading.
var defaultBuilder = &hyperliquid.BuilderInfo{
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 100,
}
// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format
// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER"
// isXyzDexAsset checks if a symbol is an xyz dex asset.
// Keep this delegated to the provider map so newly listed xyz markets such as
// SAMSUNG-USDC / SK-HYNIX-USDC cannot accidentally fall through as crypto.
func isXyzDexAsset(symbol string) bool {
return hlprovider.IsXYZAsset(symbol)
}
// convertSymbolToHyperliquid converts standard/display symbols to Hyperliquid format.
// Examples: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "SAMSUNG-USDC" -> "xyz:SMSN".
func convertSymbolToHyperliquid(symbol string) string {
// Convert to uppercase for consistent handling
base := strings.ToUpper(symbol)
// Remove common suffixes to get base symbol
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
// Remove xyz: prefix if present (case-insensitive, will be re-added if needed)
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
base = base[4:] // Remove first 4 characters
}
// Check if this is an xyz dex asset (stocks, forex, commodities)
if isXyzDexAsset(base) {
return "xyz:" + base
}
return base
return hlprovider.FormatCoinForAPI(symbol)
}
// absFloat returns absolute value of float

View File

@@ -15,6 +15,28 @@ import (
"github.com/sonirico/go-hyperliquid"
)
func (t *HyperliquidTrader) placeOrderWithBuilderFee(order hyperliquid.CreateOrderRequest) error {
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err == nil {
return nil
}
return wrapBuilderFeeNotApproved(err)
}
func isBuilderFeeNotApprovedError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "builder fee has not been approved")
}
func wrapBuilderFeeNotApproved(err error) error {
if isBuilderFeeNotApprovedError(err) {
return fmt.Errorf("Hyperliquid builder fee is not approved for NOFX; reconnect Hyperliquid wallet and complete trading authorization: %w", err)
}
return err
}
// OpenLong opens a long position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this coin
@@ -71,7 +93,7 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
err = t.placeOrderWithBuilderFee(order)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
@@ -143,7 +165,7 @@ func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
err = t.placeOrderWithBuilderFee(order)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
@@ -225,7 +247,7 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
err = t.placeOrderWithBuilderFee(order)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
@@ -312,7 +334,7 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
err = t.placeOrderWithBuilderFee(order)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
@@ -634,12 +656,13 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
},
}
// Create OrderAction (no builder to avoid requiring builder fee approval)
// Create OrderAction with NOFX builder fee. Trader startup requires a persisted
// builder approval flag before this live path can run.
action := hyperliquid.OrderAction{
Type: "order",
Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na",
Builder: nil,
Builder: defaultBuilder,
}
// Sign the action
@@ -727,7 +750,7 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
if len(result.Response.Data.Statuses) > 0 {
status := result.Response.Data.Statuses[0]
if status.Error != nil {
return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error)
return wrapBuilderFeeNotApproved(fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error))
}
if status.Filled != nil {
logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d",
@@ -798,12 +821,13 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
},
}
// Create OrderAction (no builder to avoid requiring builder fee approval)
// Create OrderAction with NOFX builder fee. Trader startup requires a persisted
// builder approval flag before this live path can run.
action := hyperliquid.OrderAction{
Type: "order",
Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na",
Builder: nil,
Builder: defaultBuilder,
}
// Sign the action
@@ -885,7 +909,7 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
if len(result.Response.Data.Statuses) > 0 {
status := result.Response.Data.Statuses[0]
if status.Error != nil {
return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error)
return wrapBuilderFeeNotApproved(fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error))
}
if status.Resting != nil {
logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid)
@@ -934,7 +958,7 @@ func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quan
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
err := t.placeOrderWithBuilderFee(order)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
@@ -982,7 +1006,7 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
err := t.placeOrderWithBuilderFee(order)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
@@ -1029,7 +1053,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type
ReduceOnly: req.ReduceOnly,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
err := t.placeOrderWithBuilderFee(order)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}