diff --git a/sai-trading/.env.example b/sai-trading/.env.example new file mode 100644 index 000000000..3ef3f40a5 --- /dev/null +++ b/sai-trading/.env.example @@ -0,0 +1,3 @@ +EVM_MNEMONIC="" +SLACK_WEBHOOK="" +SLACK_ERROR_KEYWORDS="" \ No newline at end of file diff --git a/sai-trading/README.md b/sai-trading/README.md index e7d134be7..6fdc1b66e 100644 --- a/sai-trading/README.md +++ b/sai-trading/README.md @@ -46,50 +46,76 @@ https://github.com/mikefarah/yq?tab=readme-ov-file#github-action ## Running the EVM Trader -### Configuration via `.env` file +### Configuration -Create a `.env` file in the root directory to configure the trader: +Create a `.env` file in the root directory (see `.env.example`): ```bash # Account credentials (use either private key OR mnemonic) -EVM_PRIVATE_KEY=0x1234567890abcdef... # Your private key in hex format +EVM_PRIVATE_KEY=0x1234567890abcdef... # OR -EVM_MNEMONIC="word1 word2 word3 ..." # Your BIP39 mnemonic phrase +EVM_MNEMONIC="word1 word2 word3 ..." + +# Optional: Slack notifications +SLACK_WEBHOOK="" +SLACK_ERROR_KEYWORDS="" +``` ### Running the trader -**Dynamic trading** (uses config parameters): +**Auto trading** : +```bash +just run-trader auto --network testnet +``` + +With custom parameters: ```bash -just run-trader -# or with custom parameters: -just run-trader --market-index 0 --leverage-min 5 --leverage-max 20 +just run-trader auto --market-index 0 --min-leverage 5 --max-leverage 20 --blocks-before-close 20 --network testnet ``` -**Static JSON file trading**: +Or use a config file: ```bash +go run ./cmd/trader auto --config auto-trader.localnet.json +``` + +**Manual trading**: +```bash +# Open a single position +just run-trader open --trade-type trade --market-index 0 --long false --trade-size 1 --network testnet + +# Using JSON file just run-trader --trade-json sample_txs/open_trade.json ``` ### Available flags -- `--network`: Network mode (`localnet`, `testnet`, `mainnet`) +**Root flags (shared across all commands):** +- `--network`: Network mode (`localnet`, `testnet`, `mainnet`) (default: `localnet`) +- `--evm-rpc`: EVM RPC URL (overrides network mode default) +- `--networks-toml`: Path to networks TOML configuration file (default: `networks.toml`) +- `--contracts-env`: Path to contracts env file (legacy, overrides networks.toml) - `--private-key`: Private key in hex format (overrides `EVM_PRIVATE_KEY` env var) - `--mnemonic`: BIP39 mnemonic phrase (overrides `EVM_MNEMONIC` env var) -- `--contracts-env`: Path to contracts env file (defaults to `.cache/localnet_contracts.env`) -- `--trade-json`: Path to JSON file with trade parameters (overrides dynamic trading) + +**Auto command flags:** +- `--config`: Path to JSON config file (optional) - `--market-index`: Market index to trade (default: 0) -- `--collateral-index`: Collateral token index (default: 1) -- `--leverage-min`: Minimum leverage (default: 5) -- `--leverage-max`: Maximum leverage (default: 20) -- `--trade-size-min`: Minimum trade size in smallest units (default: 10000) -- `--trade-size-max`: Maximum trade size in smallest units (default: 50000) -- `--enable-limit-order`: Enable limit order trading (default: false) +- `--collateral-index`: Collateral token index (default: 0, uses market's quote token) +- `--min-trade-size`: Minimum trade size in smallest units (default: 1000000) +- `--max-trade-size`: Maximum trade size in smallest units (default: 5000000) +- `--min-leverage`: Minimum leverage (default: 1, e.g., 1 for 1x) +- `--max-leverage`: Maximum leverage (default: 10, e.g., 10 for 10x) +- `--blocks-before-close`: Number of blocks to wait before closing a position (default: 20) +- `--max-open-positions`: Maximum number of positions to keep open at once (default: 5) +- `--loop-delay`: Delay in seconds between each loop iteration (default: 5) ### Example `.env` file ```bash # Account EVM_MNEMONIC="guard cream sadness conduct invite crumble clock pudding hole grit liar hotel maid produce squeeze return argue turtle know drive eight casino maze host" +SLACK_WEBHOOK="" +SLACK_ERROR_KEYWORDS="" ``` **Note**: The `.env` file is automatically loaded if present. You can also pass values via command-line flags, which take precedence over environment variables. diff --git a/sai-trading/auto-trader.localnet.json b/sai-trading/auto-trader.localnet.json new file mode 100644 index 000000000..500297282 --- /dev/null +++ b/sai-trading/auto-trader.localnet.json @@ -0,0 +1,20 @@ +{ + "network": { + "mode": "localnet", + "evm_rpc_url": "http://localhost:8545", + "networks_toml": "networks.toml" + }, + "trading": { + "market_index": 0, + "collateral_index": 1, + "min_trade_size": 10000, + "max_trade_size": 50000, + "min_leverage": 2, + "max_leverage": 8 + }, + "bot": { + "blocks_before_close": 20, + "max_open_positions": 3, + "loop_delay_seconds": 5 + } +} diff --git a/sai-trading/auto-trader.testnet.json b/sai-trading/auto-trader.testnet.json new file mode 100644 index 000000000..1bfeca1ff --- /dev/null +++ b/sai-trading/auto-trader.testnet.json @@ -0,0 +1,19 @@ +{ + "network": { + "mode": "testnet", + "networks_toml": "networks.toml" + }, + "trading": { + "market_index": 0, + "collateral_index": 3, + "min_trade_size": 10000, + "max_trade_size": 100000, + "min_leverage": 1, + "max_leverage": 5 + }, + "bot": { + "blocks_before_close": 10, + "max_open_positions": 2, + "loop_delay_seconds": 10 + } +} diff --git a/sai-trading/cmd/trader/main.go b/sai-trading/cmd/trader/main.go index 1c6f97d21..c2943546a 100644 --- a/sai-trading/cmd/trader/main.go +++ b/sai-trading/cmd/trader/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/NibiruChain/nibiru/sai-trading/services/evmtrader" "github.com/NibiruChain/nibiru/v2/eth" @@ -19,6 +20,7 @@ var ( // Network config (shared across subcommands) evmRPCUrl string contractsEnvFile string + networksTomlFile string networkMode string // Account (shared across subcommands) @@ -37,7 +39,8 @@ to interact with Sai Perps contracts.`, // Shared flags for all subcommands rootCmd.PersistentFlags().StringVar(&networkMode, "network", "localnet", "Network mode: localnet, testnet, or mainnet") rootCmd.PersistentFlags().StringVar(&evmRPCUrl, "evm-rpc", "", "EVM RPC URL (overrides network mode default)") - rootCmd.PersistentFlags().StringVar(&contractsEnvFile, "contracts-env", "", "Path to contracts env file (auto-detected if not set)") + rootCmd.PersistentFlags().StringVar(&networksTomlFile, "networks-toml", "networks.toml", "Path to networks TOML configuration file") + rootCmd.PersistentFlags().StringVar(&contractsEnvFile, "contracts-env", "", "Path to contracts env file (legacy, overrides networks.toml)") rootCmd.PersistentFlags().StringVar(&privateKeyHex, "private-key", "", "Private key in hex format (or set EVM_PRIVATE_KEY env var)") rootCmd.PersistentFlags().StringVar(&mnemonic, "mnemonic", "", "BIP39 mnemonic phrase (or set EVM_MNEMONIC env var)") @@ -46,6 +49,7 @@ to interact with Sai Perps contracts.`, rootCmd.AddCommand(newCloseCmd()) rootCmd.AddCommand(newListCmd()) rootCmd.AddCommand(newPositionsCmd()) + rootCmd.AddCommand(newAutoCmd()) // Default to open command for backward compatibility rootCmd.RunE = newOpenCmd().RunE @@ -61,11 +65,7 @@ func newOpenCmd() *cobra.Command { var ( // Strategy config tradeSize uint64 - tradeSizeMin uint64 - tradeSizeMax uint64 leverage uint64 - leverageMin uint64 - leverageMax uint64 long bool marketIndex uint64 collateralIndex uint64 @@ -85,9 +85,12 @@ Examples: # Market order (auto-fetch price) trader open --market-index 0 --leverage 5 --long true - # Limit order (must specify trigger price) + # Limit order with explicit trigger price trader open --trade-type limit --market-index 0 --open-price 70000 --long + # Limit order with auto-fetch price (uses oracle price as-is) + trader open --trade-type limit --market-index 0 --leverage 5 --long + # Short position trader open --market-index 0 --long=false @@ -102,23 +105,17 @@ Examples: if cmd.Flags().Changed("open-price") { openPricePtr = &openPrice } - return runOpen(tradeSize, tradeSizeMin, tradeSizeMax, leverage, leverageMin, leverageMax, longPtr, marketIndex, collateralIndex, tradeType, openPricePtr, tradeJSONFile) + return runOpen(tradeSize, leverage, longPtr, marketIndex, collateralIndex, tradeType, openPricePtr, tradeJSONFile) }, } // Strategy flags - exact values (override ranges if set) cmd.Flags().Uint64Var(&tradeSize, "trade-size", 0, "Exact trade size in smallest units (overrides min/max)") - cmd.Flags().Uint64Var(&leverage, "leverage", 0, "Exact leverage (e.g., 10 for 10x, overrides min/max)") - cmd.Flags().BoolVar(&long, "long", false, "Trade direction: true for long, false for short (default: random)") - cmd.Flags().Float64Var(&openPrice, "open-price", 0, "Open price (required for limit/stop orders, optional for market orders)") - - // Strategy flags - ranges (used if exact values not set) - cmd.Flags().Uint64Var(&tradeSizeMin, "trade-size-min", 10_000, "Minimum trade size in smallest units") - cmd.Flags().Uint64Var(&tradeSizeMax, "trade-size-max", 50_000, "Maximum trade size in smallest units") - cmd.Flags().Uint64Var(&leverageMin, "leverage-min", 5, "Minimum leverage (e.g., 5 for 5x)") - cmd.Flags().Uint64Var(&leverageMax, "leverage-max", 20, "Maximum leverage (e.g., 20 for 20x)") + cmd.Flags().Uint64Var(&leverage, "leverage", 0, "Exact leverage (e.g., 10 for 10x, default: 1)") + cmd.Flags().BoolVar(&long, "long", false, "Trade direction: true for long, false for short (default: true)") + cmd.Flags().Float64Var(&openPrice, "open-price", 0, "Open price (optional: if not set, fetched from oracle and used as-is)") cmd.Flags().Uint64Var(&marketIndex, "market-index", 0, "Market index to trade") - cmd.Flags().Uint64Var(&collateralIndex, "collateral-index", 1, "Collateral token index") + cmd.Flags().Uint64Var(&collateralIndex, "collateral-index", 0, "Collateral token index") cmd.Flags().StringVar(&tradeType, "trade-type", "", "Trade type: 'trade' (market), 'limit', or 'stop' (default: 'trade')") cmd.Flags().StringVar(&tradeJSONFile, "trade-json", "", "Path to JSON file with open_trade parameters (overrides dynamic trading)") @@ -134,7 +131,7 @@ func newCloseCmd() *cobra.Command { Short: "Close a market trade order in Sai Perps", Long: `Close a market trade order (position) in Sai Perps. -This command sends a close_trade_market message to the perp contract to close a specific trade position.`, +This command sends a close_trade message to the perp contract to close a specific trade position.`, RunE: func(cmd *cobra.Command, args []string) error { return runClose(tradeIndex) }, @@ -155,7 +152,7 @@ func newListCmd() *cobra.Command { This command queries the perp contract to display all configured markets with their details.`, RunE: func(cmd *cobra.Command, args []string) error { - return runList(cmd) + return runList() }, } @@ -178,7 +175,62 @@ This command queries the perp contract to display all trades/positions with thei return cmd } -func runOpen(tradeSize, tradeSizeMin, tradeSizeMax, leverage, leverageMin, leverageMax uint64, long *bool, marketIndex, collateralIndex uint64, tradeType string, openPrice *float64, tradeJSONFile string) error { +// newAutoCmd creates the auto subcommand +func newAutoCmd() *cobra.Command { + var ( + configFile string + marketIndex uint64 + collateralIndex uint64 + minTradeSize uint64 + maxTradeSize uint64 + minLeverage uint64 + maxLeverage uint64 + blocksBeforeClose uint64 + maxOpenPositions int + loopDelaySeconds int + ) + + cmd := &cobra.Command{ + Use: "auto", + Short: "Run automated random trading", + Long: `Run an automated trading bot that randomly opens and closes positions. + +This command continuously: +- Opens random trades with random parameters (size, leverage, direction) +- Tracks open positions and their opening block numbers +- Closes positions after a specified number of blocks +- Checks balance before opening trades to avoid insufficient funds + +Examples: + # Run with config file + trader auto --config auto-trader.json + + # Run with defaults (market 0, random size/leverage, close after 20 blocks) + trader auto + + # Custom parameters via flags (overrides config file) + trader auto --config auto-trader.json --min-leverage 5 --max-leverage 20`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAuto(configFile, marketIndex, collateralIndex, minTradeSize, maxTradeSize, + minLeverage, maxLeverage, blocksBeforeClose, maxOpenPositions, loopDelaySeconds, cmd) + }, + } + + cmd.Flags().StringVar(&configFile, "config", "", "Path to JSON config file (optional)") + cmd.Flags().Uint64Var(&marketIndex, "market-index", 0, "Market index to trade") + cmd.Flags().Uint64Var(&collateralIndex, "collateral-index", 0, "Collateral token index (default: use market's quote token)") + cmd.Flags().Uint64Var(&minTradeSize, "min-trade-size", 1000000, "Minimum trade size in smallest units") + cmd.Flags().Uint64Var(&maxTradeSize, "max-trade-size", 5000000, "Maximum trade size in smallest units") + cmd.Flags().Uint64Var(&minLeverage, "min-leverage", 1, "Minimum leverage (e.g., 1 for 1x)") + cmd.Flags().Uint64Var(&maxLeverage, "max-leverage", 10, "Maximum leverage (e.g., 10 for 10x)") + cmd.Flags().Uint64Var(&blocksBeforeClose, "blocks-before-close", 20, "Number of blocks to wait before closing a position") + cmd.Flags().IntVar(&maxOpenPositions, "max-open-positions", 5, "Maximum number of positions to keep open at once") + cmd.Flags().IntVar(&loopDelaySeconds, "loop-delay", 5, "Delay in seconds between each loop iteration") + + return cmd +} + +func runOpen(tradeSize, leverage uint64, long *bool, marketIndex, collateralIndex uint64, tradeType string, openPrice *float64, tradeJSONFile string) error { cfg, err := setupConfig(true) if err != nil { return err @@ -186,41 +238,28 @@ func runOpen(tradeSize, tradeSizeMin, tradeSizeMax, leverage, leverageMin, lever // Strategy config cfg.TradeSize = tradeSize - cfg.TradeSizeMin = tradeSizeMin - cfg.TradeSizeMax = tradeSizeMax cfg.Leverage = leverage - cfg.LeverageMin = leverageMin - cfg.LeverageMax = leverageMax cfg.Long = long cfg.MarketIndex = marketIndex cfg.CollateralIndex = collateralIndex cfg.OpenPrice = openPrice // Validate trade-type if provided if tradeType != "" { - if tradeType != "trade" && tradeType != "limit" && tradeType != "stop" { - return fmt.Errorf("invalid trade-type: %s (must be 'trade', 'limit', or 'stop')", tradeType) + if !evmtrader.IsValidTradeType(tradeType) { + return fmt.Errorf("invalid trade-type: %s (must be '%s', '%s', or '%s')", + tradeType, evmtrader.TradeTypeMarket, evmtrader.TradeTypeLimit, evmtrader.TradeTypeStop) } cfg.TradeType = tradeType - cfg.EnableLimitOrder = (tradeType == "limit" || tradeType == "stop") - // Validate open-price is provided for limit/stop orders - if (tradeType == "limit" || tradeType == "stop") && openPrice == nil { - return fmt.Errorf("--open-price is required for %s orders (trigger price)", tradeType) - } + cfg.EnableLimitOrder = evmtrader.IsLimitOrStopOrder(tradeType) + // Note: --open-price is optional for limit/stop orders + // If not provided, the price will be fetched from oracle and used as-is } else { // Default to market order if not specified - cfg.TradeType = "trade" + cfg.TradeType = evmtrader.TradeTypeMarket cfg.EnableLimitOrder = false } cfg.TradeJSONFile = tradeJSONFile - // Validate config - if cfg.TradeSizeMax > 0 && cfg.TradeSizeMin >= cfg.TradeSizeMax { - return fmt.Errorf("trade-size-min must be less than trade-size-max") - } - if cfg.LeverageMax > 0 && cfg.LeverageMin >= cfg.LeverageMax { - return fmt.Errorf("leverage-min must be less than leverage-max") - } - // Create trader ctx := context.Background() trader, err := createTrader(ctx, cfg) @@ -266,7 +305,7 @@ func runClose(tradeIndex uint64) error { return nil } -func runList(cmd *cobra.Command) error { +func runList() error { cfg, err := setupConfig(false) if err != nil { return err @@ -289,26 +328,40 @@ func runList(cmd *cobra.Command) error { // Display markets if len(markets) == 0 { fmt.Println("No markets found") - return nil + } else { + fmt.Println("Available Markets:") + fmt.Println("==================") + for _, market := range markets { + fmt.Printf("Market Index: %d\n", market.Index) + if market.BaseToken != nil { + fmt.Printf(" Base Token: %d\n", *market.BaseToken) + } + if market.QuoteToken != nil { + fmt.Printf(" Quote Token: %d\n", *market.QuoteToken) + } + if market.MaxOI != nil { + fmt.Printf(" Max OI: %s\n", *market.MaxOI) + } + if market.FeePerBlock != nil { + fmt.Printf(" Fee Per Block: %s\n", *market.FeePerBlock) + } + fmt.Println() + } } - fmt.Println("Available Markets:") - fmt.Println("==================") - for _, market := range markets { - fmt.Printf("Market Index: %d\n", market.Index) - if market.BaseToken != nil { - fmt.Printf(" Base Token: %d\n", *market.BaseToken) - } - if market.QuoteToken != nil { - fmt.Printf(" Quote Token: %d\n", *market.QuoteToken) - } - if market.MaxOI != nil { - fmt.Printf(" Max OI: %s\n", *market.MaxOI) - } - if market.FeePerBlock != nil { - fmt.Printf(" Fee Per Block: %s\n", *market.FeePerBlock) + // Query collaterals + collaterals, err := trader.QueryCollaterals(ctx) + if err != nil { + // Don't fail if collaterals query fails, just log + fmt.Fprintf(os.Stderr, "Warning: Failed to query collaterals: %v\n", err) + } else if len(collaterals) > 0 { + fmt.Println("Available Collaterals:") + fmt.Println("======================") + for _, collateral := range collaterals { + fmt.Printf("Collateral Index: %d\n", collateral.Index) + fmt.Printf(" Denom: %s\n", collateral.Denom) + fmt.Println() } - fmt.Println() } return nil @@ -336,6 +389,100 @@ func runPositions() error { return nil } +func runAuto(configFile string, marketIndex, collateralIndex, minTradeSize, maxTradeSize, minLeverage, maxLeverage, + blocksBeforeClose uint64, maxOpenPositions, loopDelaySeconds int, cmd *cobra.Command) error { + + var autoCfg evmtrader.AutoTradingConfig + + // Load from config file if provided + if configFile != "" { + jsonCfg, err := evmtrader.LoadAutoTradingConfig(configFile) + if err != nil { + return fmt.Errorf("load config file: %w", err) + } + + // Convert JSON config to AutoTradingConfig + autoCfg = jsonCfg.ToAutoTradingConfig() + + // Apply network settings from config if present + if jsonCfg.Network != nil { + if jsonCfg.Network.Mode != "" && !cmd.Flags().Changed("network") { + networkMode = jsonCfg.Network.Mode + } + if jsonCfg.Network.EVMRPCUrl != "" && !cmd.Flags().Changed("evm-rpc") { + evmRPCUrl = jsonCfg.Network.EVMRPCUrl + } + if jsonCfg.Network.NetworksToml != "" && !cmd.Flags().Changed("networks-toml") { + networksTomlFile = jsonCfg.Network.NetworksToml + } + } + + fmt.Printf("Loaded config from: %s\n", configFile) + } else { + // Use command-line flags as defaults + autoCfg = evmtrader.AutoTradingConfig{ + MarketIndex: marketIndex, + CollateralIndex: collateralIndex, + MinTradeSize: minTradeSize, + MaxTradeSize: maxTradeSize, + MinLeverage: minLeverage, + MaxLeverage: maxLeverage, + BlocksBeforeClose: blocksBeforeClose, + MaxOpenPositions: maxOpenPositions, + LoopDelaySeconds: loopDelaySeconds, + } + } + + // Override config file with command-line flags if they were explicitly set + if cmd.Flags().Changed("market-index") { + autoCfg.MarketIndex = marketIndex + } + if cmd.Flags().Changed("collateral-index") { + autoCfg.CollateralIndex = collateralIndex + } + if cmd.Flags().Changed("min-trade-size") { + autoCfg.MinTradeSize = minTradeSize + } + if cmd.Flags().Changed("max-trade-size") { + autoCfg.MaxTradeSize = maxTradeSize + } + if cmd.Flags().Changed("min-leverage") { + autoCfg.MinLeverage = minLeverage + } + if cmd.Flags().Changed("max-leverage") { + autoCfg.MaxLeverage = maxLeverage + } + if cmd.Flags().Changed("blocks-before-close") { + autoCfg.BlocksBeforeClose = blocksBeforeClose + } + if cmd.Flags().Changed("max-open-positions") { + autoCfg.MaxOpenPositions = maxOpenPositions + } + if cmd.Flags().Changed("loop-delay") { + autoCfg.LoopDelaySeconds = loopDelaySeconds + } + + cfg, err := setupConfig(true) + if err != nil { + return err + } + + // Create trader + ctx := context.Background() + trader, err := createTrader(ctx, cfg) + if err != nil { + return err + } + defer trader.Close() + + // Run the auto-trading loop + if err := trader.RunAutoTrading(ctx, autoCfg); err != nil { + return fmt.Errorf("auto trading: %w", err) + } + + return nil +} + // setupConfig creates and configures an EVMTrader config with network settings and authentication. // requireAuth determines whether a valid private key is required (false for read-only queries). func setupConfig(requireAuth bool) (evmtrader.Config, error) { @@ -344,39 +491,77 @@ func setupConfig(requireAuth bool) (evmtrader.Config, error) { cfg := evmtrader.Config{} - // Set network defaults if not overridden + // Try to load from TOML file first (unless --contracts-env is explicitly set for legacy mode) var grpcUrl, chainID string - if evmRPCUrl == "" { - switch networkMode { - case "localnet": - evmRPCUrl = "http://localhost:8545" - grpcUrl = "localhost:9090" - chainID = "nibiru-localnet-0" - // case "testnet": - // evmRPCUrl = "https://evm-rpc.testnet-2.nibiru.fi" - // grpcUrl = "grpc.testnet-2.nibiru.fi:443" - // chainID = "nibiru-testnet-2" - // case "mainnet": - // evmRPCUrl = "https://evm-rpc.nibiru.fi" - // grpcUrl = "grpc.nibiru.fi:443" - // chainID = "nibiru-mainnet-1" - default: - return cfg, fmt.Errorf("unknown network mode: %s (use: localnet, testnet, mainnet)", networkMode) - } - } else { - // If EVM RPC is set but gRPC/ChainID aren't, use localnet defaults - if grpcUrl == "" { - grpcUrl = "localhost:9090" + useTOML := contractsEnvFile == "" && networksTomlFile != "" + + if useTOML { + // Check if TOML file exists + if _, err := os.Stat(networksTomlFile); err == nil { + // Load network config from TOML + networkConfig, err := evmtrader.LoadNetworkConfig(networksTomlFile) + if err != nil { + // Fall back to hardcoded defaults if TOML fails to load + fmt.Fprintf(os.Stderr, "Warning: Failed to load TOML config: %v, using hardcoded defaults\n", err) + useTOML = false + } else { + netInfo, err := evmtrader.GetNetworkInfo(networkConfig, networkMode) + if err != nil { + return cfg, err + } + + // Use TOML config unless overridden by flags + if evmRPCUrl == "" { + evmRPCUrl = netInfo.EVMRPCUrl + } + grpcUrl = netInfo.GrpcUrl + chainID = netInfo.ChainID + + // Load contract addresses from TOML + contractAddrs := evmtrader.ContractAddressesFromNetworkInfo(netInfo) + cfg.ContractAddresses = &contractAddrs + } + } else { + // TOML file doesn't exist, fall back to hardcoded defaults + useTOML = false } - if chainID == "" { - chainID = "nibiru-localnet-0" + } + + // Fall back to hardcoded defaults if not using TOML or if TOML failed + if !useTOML { + if evmRPCUrl == "" { + switch networkMode { + case "localnet": + evmRPCUrl = "http://localhost:8545" + grpcUrl = "localhost:9090" + chainID = "nibiru-localnet-0" + case "testnet": + evmRPCUrl = "https://evm-rpc.testnet-2.nibiru.fi" + grpcUrl = "grpc.testnet-2.nibiru.fi:443" + chainID = "nibiru-testnet-2" + case "mainnet": + evmRPCUrl = "https://evm-rpc.nibiru.fi" + grpcUrl = "grpc.nibiru.fi:443" + chainID = "nibiru-mainnet-1" + default: + return cfg, fmt.Errorf("unknown network mode: %s (use: localnet, testnet, mainnet)", networkMode) + } + } else { + // If EVM RPC is set but gRPC/ChainID aren't, use localnet defaults + if grpcUrl == "" { + grpcUrl = "localhost:9090" + } + if chainID == "" { + chainID = "nibiru-localnet-0" + } } } + cfg.EVMRPCUrl = evmRPCUrl cfg.GrpcUrl = grpcUrl cfg.ChainID = chainID - // Auto-detect contracts env file if not provided + // Auto-detect contracts env file if not provided (legacy mode) if contractsEnvFile == "" { contractsEnvFile = detectContractsEnvFile(networkMode) } @@ -414,6 +599,19 @@ func setupConfig(requireAuth bool) (evmtrader.Config, error) { cfg.PrivateKeyHex = privKey + // Load Slack webhook from environment + cfg.SlackWebhook = os.Getenv("SLACK_WEBHOOK") + + // Load Slack error keywords filter from environment (comma-separated) + keywordsEnv := os.Getenv("SLACK_ERROR_KEYWORDS") + if keywordsEnv != "" { + keywords := strings.Split(keywordsEnv, ",") + for i := range keywords { + keywords[i] = strings.TrimSpace(keywords[i]) + } + cfg.SlackErrorKeywords = keywords + } + return cfg, nil } diff --git a/sai-trading/go.mod b/sai-trading/go.mod index 25c3bafa1..ee59c26d2 100644 --- a/sai-trading/go.mod +++ b/sai-trading/go.mod @@ -22,6 +22,7 @@ require ( require ( github.com/joho/godotenv v1.5.1 + github.com/pelletier/go-toml/v2 v2.1.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 google.golang.org/grpc v1.62.1 @@ -74,7 +75,8 @@ require ( github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect github.com/coinbase/rosetta-sdk-go v0.7.9 // indirect github.com/confio/ics23/go v0.9.0 // indirect - github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/consensys/bavard v0.1.13 // indirect + github.com/consensys/gnark-crypto v0.12.1 // indirect github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/cosmos-db v1.0.2 // indirect github.com/cosmos/go-bip39 v1.0.0 // indirect @@ -167,10 +169,10 @@ require ( github.com/minio/highwayhash v1.0.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/petermattis/goid v0.0.0-20230317030725-371a4b8eda08 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -236,6 +238,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.6 // indirect pgregory.net/rapid v1.1.0 // indirect + rsc.io/tmplfunc v0.0.3 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/sai-trading/go.sum b/sai-trading/go.sum index ae993302e..f9edeed3b 100644 --- a/sai-trading/go.sum +++ b/sai-trading/go.sum @@ -889,12 +889,12 @@ github.com/cometbft/cometbft-db v0.11.0/go.mod h1:GDPJAC/iFHNjmZZPN8V8C1yr/eyity github.com/confio/ics23/go v0.9.0 h1:cWs+wdbS2KRPZezoaaj+qBleXgUk5WOQFMP3CQFGTr4= github.com/confio/ics23/go v0.9.0/go.mod h1:4LPZ2NYqnYIVRklaozjNR1FScgDJ2s5Xrp+e/mYVRak= github.com/consensys/bavard v0.1.8-0.20210915155054-088da2f7f54a/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= +github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= github.com/consensys/gnark-crypto v0.5.3/go.mod h1:hOdPlWQV1gDLp7faZVeg8Y0iEPFaOUnCc4XeCCk96p0= github.com/consensys/gnark-crypto v0.10.0/go.mod h1:Iq/P3HHl0ElSjsg2E1gsMwhAyxnxoKK5nVyZKd+/KhU= +github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= -github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= -github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= @@ -1496,9 +1496,8 @@ github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4F github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= -github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= -github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= @@ -1590,6 +1589,7 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -2941,6 +2941,7 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= diff --git a/sai-trading/justfile b/sai-trading/justfile index e392e066d..a325b4bd4 100644 --- a/sai-trading/justfile +++ b/sai-trading/justfile @@ -16,4 +16,13 @@ e2e-deploy: bun run e2e_deploy.ts run-trader *args: - go run ./cmd/trader {{args}} + go run ./cmd/trader {{args}} + +run-auto-trader *args: + go run ./cmd/trader auto {{args}} + +run-auto-localnet: + go run ./cmd/trader auto --config auto-trader.localnet.json + +run-auto-testnet: + go run ./cmd/trader auto --config auto-trader.testnet.json diff --git a/sai-trading/networks.toml b/sai-trading/networks.toml new file mode 100644 index 000000000..d03d4f935 --- /dev/null +++ b/sai-trading/networks.toml @@ -0,0 +1,47 @@ +# Network Configuration for Sai Trading +# This file contains contract addresses and RPC endpoints for different networks + +[localnet] +name = "Local Testnet" +evm_rpc_url = "http://localhost:8545" +grpc_url = "localhost:9090" +chain_id = "nibiru-localnet-0" + +[localnet.contracts] +oracle_address = "nibi18cszlvm6pze0x9sz32qnjq4vtd45xehqs8dq7cwy8yhq35wfnn3qm02xqh" +perp_address = "nibi1suhgf5svhu4usrurvxzlgn54ksxmn8gljarjtxqnapv8kjnp4nrs0gfase" +vault_address = "nibi1aakfpghcanxtc45gpqlx8j3rq0zcpyf49qmhm9mdjrfx036h4z5sxn9f7a" + +[localnet.tokens] +stnibi_evm = "0x7D4B7B8CA7E1a24928Bb96D59249c7a5bd1DfBe6" +stnibi_denom = "tf/nibi1zaavvzxez0elundtn32qnk9lkm8kmcsz44g7xl/stnibi" + +[testnet] +name = "Nibiru Testnet 2" +evm_rpc_url = "https://evm-rpc.testnet-2.nibiru.fi" +grpc_url = "grpc.testnet-2.nibiru.fi:443" +chain_id = "nibiru-testnet-2" + +[testnet.contracts] +oracle_address = "nibi1mqlrsvfhm5vzsz0wxr6mh8pzxzpz6dd4g7nuyycjf6gy5zc53fvq3lq2fz" +perp_address = "nibi1qtkcns647w959cj9x2yytateu6dgscfnfkraywwa443pr2erak0s5ux7e5" +evm_interface = "0x282F097930E24e4fba97EE8687d15Ed1f298ad19" + +[testnet.tokens] +usdc_evm = "0xAb68f1D1d91854383fd4Df9016E3040D03e8191a" +stnibi_evm = "0xCae3d404AFB50016154a4B18091351065154E9bD" + +[mainnet] +name = "Nibiru Mainnet" +evm_rpc_url = "https://evm-rpc.nibiru.fi" +grpc_url = "grpc.nibiru.fi:443" +chain_id = "nibiru-mainnet-1" + +[mainnet.contracts] +oracle_address = "nibi1xfwyfwtdame6645lgcs4xvf4u0hpsuvxrcelfwtztu0pv7n4l6hqw5a8gj" +perp_address = "nibi1ntmw2dfvd0qnw5fnwdu9pev2hsnqfdj9ny9n0nzh2a5u8v0scflq930mph" +evm_interface = "0x9F48A925Dda8528b3A5c2A6717Df0F03c8b167c0" + +[mainnet.tokens] +usdc_evm = "0x0829F361A05D993d5CEb035cA6DF3446b060970b" +stnibi_evm = "0xcA0a9Fb5FBF692fa12fD13c0A900EC56Bb3f0a7b" diff --git a/sai-trading/services/evmtrader/auto_config.go b/sai-trading/services/evmtrader/auto_config.go new file mode 100644 index 000000000..cab1e3be1 --- /dev/null +++ b/sai-trading/services/evmtrader/auto_config.go @@ -0,0 +1,110 @@ +package evmtrader + +import ( + "encoding/json" + "fmt" + "os" +) + +// AutoTradingJSONConfig represents the JSON configuration file structure +type AutoTradingJSONConfig struct { + // Network settings (optional, can use command-line flags instead) + Network *NetworkSettings `json:"network,omitempty"` + + // Trading parameters + Trading TradingSettings `json:"trading"` + + // Bot behavior + Bot BotSettings `json:"bot"` +} + +// NetworkSettings contains network-related configuration +type NetworkSettings struct { + Mode string `json:"mode"` // "localnet", "testnet", "mainnet" + EVMRPCUrl string `json:"evm_rpc_url"` // Optional override + NetworksToml string `json:"networks_toml"` // Path to networks.toml +} + +// TradingSettings contains trading strategy parameters +type TradingSettings struct { + MarketIndex uint64 `json:"market_index"` + CollateralIndex uint64 `json:"collateral_index"` + MinTradeSize uint64 `json:"min_trade_size"` + MaxTradeSize uint64 `json:"max_trade_size"` + MinLeverage uint64 `json:"min_leverage"` + MaxLeverage uint64 `json:"max_leverage"` +} + +// BotSettings contains bot behavior parameters +type BotSettings struct { + BlocksBeforeClose uint64 `json:"blocks_before_close"` + MaxOpenPositions int `json:"max_open_positions"` + LoopDelaySeconds int `json:"loop_delay_seconds"` +} + +// LoadAutoTradingConfig loads the auto-trading configuration from a JSON file +func LoadAutoTradingConfig(configPath string) (*AutoTradingJSONConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + var cfg AutoTradingJSONConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config JSON: %w", err) + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return &cfg, nil +} + +// Validate checks if the configuration is valid +func (cfg *AutoTradingJSONConfig) Validate() error { + // Validate trading settings + if cfg.Trading.MinTradeSize > cfg.Trading.MaxTradeSize { + return fmt.Errorf("min_trade_size (%d) cannot be greater than max_trade_size (%d)", + cfg.Trading.MinTradeSize, cfg.Trading.MaxTradeSize) + } + if cfg.Trading.MinLeverage > cfg.Trading.MaxLeverage { + return fmt.Errorf("min_leverage (%d) cannot be greater than max_leverage (%d)", + cfg.Trading.MinLeverage, cfg.Trading.MaxLeverage) + } + if cfg.Trading.MinLeverage == 0 { + return fmt.Errorf("min_leverage must be at least 1") + } + if cfg.Trading.MinTradeSize == 0 { + return fmt.Errorf("min_trade_size must be greater than 0") + } + + // Validate bot settings + if cfg.Bot.BlocksBeforeClose == 0 { + return fmt.Errorf("blocks_before_close must be greater than 0") + } + if cfg.Bot.MaxOpenPositions <= 0 { + return fmt.Errorf("max_open_positions must be greater than 0") + } + if cfg.Bot.LoopDelaySeconds <= 0 { + return fmt.Errorf("loop_delay_seconds must be greater than 0") + } + + return nil +} + +// ToAutoTradingConfig converts JSON config to AutoTradingConfig +func (cfg *AutoTradingJSONConfig) ToAutoTradingConfig() AutoTradingConfig { + return AutoTradingConfig{ + MarketIndex: cfg.Trading.MarketIndex, + CollateralIndex: cfg.Trading.CollateralIndex, + MinTradeSize: cfg.Trading.MinTradeSize, + MaxTradeSize: cfg.Trading.MaxTradeSize, + MinLeverage: cfg.Trading.MinLeverage, + MaxLeverage: cfg.Trading.MaxLeverage, + BlocksBeforeClose: cfg.Bot.BlocksBeforeClose, + MaxOpenPositions: cfg.Bot.MaxOpenPositions, + LoopDelaySeconds: cfg.Bot.LoopDelaySeconds, + } +} diff --git a/sai-trading/services/evmtrader/auto_trader.go b/sai-trading/services/evmtrader/auto_trader.go new file mode 100644 index 000000000..b58406cf8 --- /dev/null +++ b/sai-trading/services/evmtrader/auto_trader.go @@ -0,0 +1,346 @@ +package evmtrader + +import ( + "context" + "crypto/rand" + "math/big" + "strings" + "time" +) + +// AutoTradingConfig holds configuration for automated trading +type AutoTradingConfig struct { + MarketIndex uint64 + CollateralIndex uint64 + MinTradeSize uint64 + MaxTradeSize uint64 + MinLeverage uint64 + MaxLeverage uint64 + BlocksBeforeClose uint64 + MaxOpenPositions int + LoopDelaySeconds int +} + +// PositionTracker tracks an open position and when it was opened +type PositionTracker struct { + TradeIndex uint64 + OpenBlock uint64 + MarketIndex uint64 +} + +// RunAutoTrading runs the automated trading loop +func (t *EVMTrader) RunAutoTrading(ctx context.Context, cfg AutoTradingConfig) error { + t.log("Starting automated trading", + "market_index", cfg.MarketIndex, + "min_trade_size", cfg.MinTradeSize, + "max_trade_size", cfg.MaxTradeSize, + "min_leverage", cfg.MinLeverage, + "max_leverage", cfg.MaxLeverage, + "blocks_before_close", cfg.BlocksBeforeClose, + "max_open_positions", cfg.MaxOpenPositions, + ) + + // Map to track positions we've opened (tradeIndex -> PositionTracker) + trackedPositions := make(map[uint64]*PositionTracker) + + for { + // Get current block number + currentBlock, err := t.client.BlockNumber(ctx) + if err != nil { + t.logError("Failed to get block number", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + + t.log("Auto-trading loop iteration", "current_block", currentBlock, "tracked_positions", len(trackedPositions)) + + // Query current open positions + trades, err := t.QueryTrades(ctx) + if err != nil { + t.logError("Failed to query trades", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + + // Filter for open positions + openPositions := make([]ParsedTrade, 0) + for _, trade := range trades { + if trade.IsOpen { + openPositions = append(openPositions, trade) + } + } + + t.log("Found open positions", "count", len(openPositions)) + + // Check if any tracked positions should be closed + // Only close one position per block iteration to avoid nonce conflicts + closedOne := false + for _, trade := range openPositions { + tracker, isTracked := trackedPositions[trade.UserTradeIndex] + if !isTracked { + // Position not tracked (may have been opened before bot started) + // Add it to tracking with current block as open block + trackedPositions[trade.UserTradeIndex] = &PositionTracker{ + TradeIndex: trade.UserTradeIndex, + OpenBlock: currentBlock, + MarketIndex: trade.MarketIndex, + } + t.log("Added existing position to tracking", "trade_index", trade.UserTradeIndex, "current_block", currentBlock) + continue + } + + // Check if position should be closed + blocksSinceOpen := currentBlock - tracker.OpenBlock + if blocksSinceOpen >= cfg.BlocksBeforeClose { + // Only close one position per block iteration + if closedOne { + continue + } + + t.log("Closing position (reached block threshold)", + "trade_index", trade.UserTradeIndex, + "blocks_since_open", blocksSinceOpen, + "threshold", cfg.BlocksBeforeClose, + ) + + if err := t.CloseTrade(ctx, trade.UserTradeIndex); err != nil { + t.logError("Failed to close trade", "trade_index", trade.UserTradeIndex, "error", err.Error()) + } else { + // Remove from tracking + delete(trackedPositions, trade.UserTradeIndex) + // Wait 2 seconds after closing to ensure nonce is updated before next operation + time.Sleep(2 * time.Second) + } + + // Mark that we've closed one position this iteration + closedOne = true + break + } + } + + // Check if we should open a new position + // Only open if we didn't close a position in this iteration (to avoid nonce conflicts) + if !closedOne && len(openPositions) < cfg.MaxOpenPositions { + t.log("Opening new random position", "current_positions", len(openPositions), "max", cfg.MaxOpenPositions) + + // Generate random trade parameters + tradeSize := randomUint64(cfg.MinTradeSize, cfg.MaxTradeSize) + leverage := randomUint64(cfg.MinLeverage, cfg.MaxLeverage) + isLong := randomBool() + tradeType := randomTradeType() + + // Check balance before opening + erc20ABI := getERC20ABI() + erc20Addr := t.addrs.TokenStNIBIERC20 + if erc20Addr == "" { + t.log("ERC20 address not configured, skipping balance check") + } else { + balance, err := t.queryERC20BalanceFromString(ctx, erc20ABI, erc20Addr, t.accountAddr) + if err != nil { + t.logError("Failed to query balance", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + + requiredBalance := new(big.Int).SetUint64(tradeSize) + if balance.Cmp(requiredBalance) < 0 { + t.logError("Insufficient balance for trade", + "balance", balance.String(), + "required", requiredBalance.String(), + ) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + } + + // Determine collateral index + collateralIndex := cfg.CollateralIndex + if collateralIndex == 0 { + market, err := t.queryMarket(ctx, cfg.MarketIndex) + if err != nil { + t.logError("Failed to query market for collateral index", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + if market.QuoteToken == nil { + t.logError("Market has no quote token", "market_index", cfg.MarketIndex) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + collateralIndex = *market.QuoteToken + } + + // Open the trade + chainID, err := t.client.ChainID(ctx) + if err != nil { + t.logError("Failed to get chain ID", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + + // Fetch current market price from oracle (needed for all trade types) + marketPrice, err := t.fetchMarketPriceForIndex(ctx, cfg.MarketIndex) + if err != nil { + t.logError("Failed to fetch market price from oracle", "error", err.Error()) + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + continue + } + + // Determine open_price based on trade type + var openPrice *float64 + if tradeType == TradeTypeMarket { + // For market orders, use current market price + openPrice = &marketPrice + } else { + // For limit/stop orders, adjust price by ±2-5% to create trigger price + adjustmentPercent := randomFloat64(2.0, 5.0) / 100.0 + if isLong { + // For long limit orders, set trigger price below market (buy cheaper) + // For long stop orders, set trigger price below market (stop loss when price drops) + if tradeType == TradeTypeLimit { + triggerPrice := marketPrice * (1.0 - adjustmentPercent) + openPrice = &triggerPrice + } else { // stop + triggerPrice := marketPrice * (1.0 - adjustmentPercent) + openPrice = &triggerPrice + } + } else { + // For short limit orders, set trigger price above market (sell higher) + // For short stop orders, set trigger price above market (stop loss when price rises) + if tradeType == TradeTypeLimit { + triggerPrice := marketPrice * (1.0 + adjustmentPercent) + openPrice = &triggerPrice + } else { // stop + triggerPrice := marketPrice * (1.0 + adjustmentPercent) + openPrice = &triggerPrice + } + } + } + + params := &OpenTradeParams{ + MarketIndex: cfg.MarketIndex, + Leverage: leverage, + Long: isLong, + CollateralIndex: collateralIndex, + TradeType: tradeType, + OpenPrice: openPrice, + TP: nil, + SL: nil, + SlippageP: "1", + CollateralAmt: new(big.Int).SetUint64(tradeSize), + } + + t.log("Opening random trade", + "trade_size", tradeSize, + "leverage", leverage, + "long", isLong, + "trade_type", tradeType, + "market_index", cfg.MarketIndex, + "collateral_index", collateralIndex, + "open_price", *openPrice, + ) + + if err := t.OpenTrade(ctx, chainID, params); err != nil { + t.logError("Failed to open trade", + "error", err.Error(), + "trade_type", tradeType, + "leverage", leverage, + "long", isLong, + "trade_size", tradeSize, + "market_index", cfg.MarketIndex, + ) + // If it's a nonce error, wait a bit longer before retrying + if strings.Contains(err.Error(), "invalid nonce") { + t.logError("Nonce conflict detected", + "error", err.Error(), + "action", "waiting before next iteration", + ) + time.Sleep(3 * time.Second) + } + } else { + // Query trades again to find the new position and add it to tracking + newTrades, err := t.QueryTrades(ctx) + if err != nil { + t.logError("Failed to query trades after opening", "error", err.Error()) + } else { + // Find the newest open position that we're not tracking yet + for _, trade := range newTrades { + if trade.IsOpen && trackedPositions[trade.UserTradeIndex] == nil { + trackedPositions[trade.UserTradeIndex] = &PositionTracker{ + TradeIndex: trade.UserTradeIndex, + OpenBlock: currentBlock, + MarketIndex: trade.MarketIndex, + } + t.log("Added new position to tracking", + "trade_index", trade.UserTradeIndex, + "open_block", currentBlock, + ) + break + } + } + } + // Wait 2 seconds after successfully opening a trade to ensure nonce is updated + time.Sleep(2 * time.Second) + } + } else { + t.log("Maximum open positions reached, waiting to close positions", "current", len(openPositions), "max", cfg.MaxOpenPositions) + } + + // Sleep before next iteration + time.Sleep(time.Duration(cfg.LoopDelaySeconds) * time.Second) + } +} + +// randomUint64 returns a random uint64 between min and max (inclusive) +func randomUint64(min, max uint64) uint64 { + if min >= max { + return min + } + diff := max - min + 1 + n, err := rand.Int(rand.Reader, big.NewInt(int64(diff))) + if err != nil { + // Fallback to min on error + return min + } + return min + n.Uint64() +} + +// randomBool returns a cryptographically secure random boolean +func randomBool() bool { + var b [1]byte + if _, err := rand.Read(b[:]); err != nil { + return false + } + return b[0]&1 == 1 +} + +// randomTradeType returns a random trade type (market, limit, or stop) +func randomTradeType() string { + // Randomly select between market (50%), limit (25%), and stop (25%) + n := randomUint64(0, 3) + switch n { + case 0, 1: + return TradeTypeMarket + case 2: + return TradeTypeLimit + default: + return TradeTypeStop + } +} + +// randomFloat64 returns a random float64 between min and max +func randomFloat64(min, max float64) float64 { + if min >= max { + return min + } + diff := max - min + // Generate random bytes and convert to float64 + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return min + } + // Convert bytes to uint64, then to float64 in range [0, 1) + randUint64 := new(big.Int).SetBytes(b[:]).Uint64() + randFloat := float64(randUint64) / float64(^uint64(0)) + return min + randFloat*diff +} diff --git a/sai-trading/services/evmtrader/config.go b/sai-trading/services/evmtrader/config.go index 842845942..6b2e8e3e3 100644 --- a/sai-trading/services/evmtrader/config.go +++ b/sai-trading/services/evmtrader/config.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/pelletier/go-toml/v2" ) // Config holds runtime configuration for the EVM trader service. @@ -14,10 +16,16 @@ type Config struct { GrpcUrl string ChainID string ContractsEnvFile string + // ContractAddresses can be set directly (from TOML) or loaded from ContractsEnvFile + ContractAddresses *ContractAddresses // Account PrivateKeyHex string + // Notifications + SlackWebhook string + SlackErrorKeywords []string // Only send to Slack if error contains these keywords (empty = send all) + // Strategy TradeSize uint64 // Exact trade size (if set, overrides min/max) TradeSizeMin uint64 @@ -45,6 +53,38 @@ type ContractAddresses struct { StNIBIDenom string } +// NetworkConfig represents the TOML configuration for all networks +type NetworkConfig struct { + Localnet NetworkInfo `toml:"localnet"` + Testnet NetworkInfo `toml:"testnet"` + Mainnet NetworkInfo `toml:"mainnet"` +} + +// NetworkInfo contains configuration for a specific network +type NetworkInfo struct { + Name string `toml:"name"` + EVMRPCUrl string `toml:"evm_rpc_url"` + GrpcUrl string `toml:"grpc_url"` + ChainID string `toml:"chain_id"` + Contracts ContractConfig `toml:"contracts"` + Tokens TokenConfig `toml:"tokens"` +} + +// ContractConfig contains contract addresses +type ContractConfig struct { + OracleAddress string `toml:"oracle_address"` + PerpAddress string `toml:"perp_address"` + VaultAddress string `toml:"vault_address"` + EVMInterface string `toml:"evm_interface"` +} + +// TokenConfig contains token addresses +type TokenConfig struct { + USDCEvm string `toml:"usdc_evm"` + StNIBIEvm string `toml:"stnibi_evm"` + StNIBIDenom string `toml:"stnibi_denom"` +} + // loadContractAddresses reads a simple KEY=VALUE env file. func loadContractAddresses(envFile string) (ContractAddresses, error) { data, err := os.ReadFile(envFile) @@ -80,6 +120,46 @@ func loadContractAddresses(envFile string) (ContractAddresses, error) { return addrs, nil } +// LoadNetworkConfig loads network configuration from TOML file +func LoadNetworkConfig(tomlFile string) (NetworkConfig, error) { + data, err := os.ReadFile(tomlFile) + if err != nil { + return NetworkConfig{}, fmt.Errorf("read TOML file: %w", err) + } + + var config NetworkConfig + if err := toml.Unmarshal(data, &config); err != nil { + return NetworkConfig{}, fmt.Errorf("parse TOML: %w", err) + } + + return config, nil +} + +// GetNetworkInfo returns the NetworkInfo for a given network mode +func GetNetworkInfo(config NetworkConfig, networkMode string) (*NetworkInfo, error) { + switch networkMode { + case "localnet": + return &config.Localnet, nil + case "testnet": + return &config.Testnet, nil + case "mainnet": + return &config.Mainnet, nil + default: + return nil, fmt.Errorf("unknown network mode: %s", networkMode) + } +} + +// ContractAddressesFromNetworkInfo converts NetworkInfo to ContractAddresses +func ContractAddressesFromNetworkInfo(netInfo *NetworkInfo) ContractAddresses { + return ContractAddresses{ + OracleAddress: netInfo.Contracts.OracleAddress, + PerpAddress: netInfo.Contracts.PerpAddress, + VaultAddress: netInfo.Contracts.VaultAddress, + TokenStNIBIERC20: netInfo.Tokens.StNIBIEvm, + StNIBIDenom: netInfo.Tokens.StNIBIDenom, + } +} + // normalizeConfigPaths normalizes file paths in config to be resilient to different CWDs. func normalizeConfigPaths(cfg *Config) { if !filepath.IsAbs(cfg.ContractsEnvFile) && cfg.ContractsEnvFile != "" { diff --git a/sai-trading/services/evmtrader/evm_trader.go b/sai-trading/services/evmtrader/evm_trader.go index 87b8e67a7..570daf78e 100644 --- a/sai-trading/services/evmtrader/evm_trader.go +++ b/sai-trading/services/evmtrader/evm_trader.go @@ -1,11 +1,14 @@ package evmtrader import ( + "bytes" "context" "crypto/ecdsa" + "crypto/tls" "encoding/json" "fmt" "math/big" + "net/http" "os" "strings" "time" @@ -18,6 +21,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) @@ -66,8 +70,15 @@ func New(ctx context.Context, cfg Config) (*EVMTrader, error) { } // Connect to gRPC for transaction broadcasting + // Use TLS for remote servers (testnet/mainnet), insecure for localhost + var grpcCreds credentials.TransportCredentials + if strings.Contains(cfg.GrpcUrl, ":443") || (!strings.Contains(cfg.GrpcUrl, "localhost") && !strings.Contains(cfg.GrpcUrl, "127.0.0.1")) { + grpcCreds = credentials.NewTLS(&tls.Config{}) + } else { + grpcCreds = insecure.NewCredentials() + } grpcConn, err := grpc.DialContext(ctx, cfg.GrpcUrl, - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithTransportCredentials(grpcCreds), ) if err != nil { return nil, fmt.Errorf("dial grpc: %w", err) @@ -77,11 +88,18 @@ func New(ctx context.Context, cfg Config) (*EVMTrader, error) { encCfg := getEncConfig() txClient := txtypes.NewServiceClient(grpcConn) - addrs, err := loadContractAddresses(cfg.ContractsEnvFile) - if err != nil { - return nil, fmt.Errorf("load contracts: %w", err) + // Load contract addresses: use from Config if provided, otherwise load from file + var addrs ContractAddresses + if cfg.ContractAddresses != nil { + addrs = *cfg.ContractAddresses + } else { + var err error + addrs, err = loadContractAddresses(cfg.ContractsEnvFile) + if err != nil { + return nil, fmt.Errorf("load contracts: %w", err) + } } - return &EVMTrader{ + trader := &EVMTrader{ cfg: cfg, client: client, txClient: txClient, @@ -91,7 +109,9 @@ func New(ctx context.Context, cfg Config) (*EVMTrader, error) { ethPrivKey: ethPrivKey, accountAddr: accountAddr, addrs: addrs, - }, nil + } + + return trader, nil } // Close releases underlying resources. @@ -159,7 +179,7 @@ func (t *EVMTrader) OpenTrade(ctx context.Context, chainID *big.Int, params *Ope } // Parse trade ID from response - isLimitOrder := params.TradeType == "limit" || params.TradeType == "stop" + isLimitOrder := isLimitOrStopOrder(params.TradeType) tradeID, err := t.parseTradeID(txResp) if err != nil { t.log("Failed to parse trade ID", "error", err.Error(), "tx_hash", txResp.TxHash) @@ -188,7 +208,7 @@ func (t *EVMTrader) CloseTrade(ctx context.Context, tradeIndex uint64) error { return fmt.Errorf("chain id: %w", err) } - // Build close_trade_market message + // Build close_trade message msgBytes, err := t.buildCloseTradeMessage(tradeIndex) if err != nil { return fmt.Errorf("build message: %w", err) @@ -221,6 +241,115 @@ func (t *EVMTrader) log(msg string, kv ...any) { _ = json.NewEncoder(os.Stdout).Encode(fields) } +// logError logs an error and optionally sends it to Slack webhook +func (t *EVMTrader) logError(msg string, kv ...any) { + t.log(msg, kv...) + + // Check if Slack webhook is configured + if t.cfg.SlackWebhook == "" { + return + } + + // Build error message for Slack + errorFields := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + k, _ := kv[i].(string) + errorFields[k] = kv[i+1] + } + + // Check if error matches keyword filter + if len(t.cfg.SlackErrorKeywords) > 0 { + matched := false + + // Check if message contains any keyword + for _, keyword := range t.cfg.SlackErrorKeywords { + if strings.Contains(strings.ToLower(msg), strings.ToLower(keyword)) { + matched = true + break + } + } + + // If message didn't match, check error fields + if !matched { + for _, v := range errorFields { + vStr := fmt.Sprintf("%v", v) + for _, keyword := range t.cfg.SlackErrorKeywords { + if strings.Contains(strings.ToLower(vStr), strings.ToLower(keyword)) { + matched = true + break + } + } + if matched { + break + } + } + } + + // If no keywords matched, don't send to Slack + if !matched { + return + } + } + + // Format Slack message + slackMsg := map[string]interface{}{ + "text": fmt.Sprintf("🚨 Auto-Trader Error: %s", msg), + "blocks": []map[string]interface{}{ + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": fmt.Sprintf("*%s*\n\n*Details:*", msg), + }, + }, + { + "type": "section", + "fields": buildSlackFields(errorFields), + }, + }, + } + + // Send to Slack (non-blocking) + go sendSlackNotification(t.cfg.SlackWebhook, slackMsg) +} + +// buildSlackFields converts error fields to Slack field format +func buildSlackFields(fields map[string]any) []map[string]interface{} { + slackFields := []map[string]interface{}{} + for k, v := range fields { + slackFields = append(slackFields, map[string]interface{}{ + "type": "mrkdwn", + "text": fmt.Sprintf("*%s:*\n%s", k, fmt.Sprintf("%v", v)), + }) + if len(slackFields) >= 10 { // Slack has a limit on fields + break + } + } + return slackFields +} + +// sendSlackNotification sends a notification to Slack webhook +func sendSlackNotification(webhookURL string, payload map[string]interface{}) { + jsonData, err := json.Marshal(payload) + if err != nil { + return // Silently fail if we can't marshal + } + + req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonData)) + if err != nil { + return // Silently fail if we can't create request + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return // Silently fail if request fails + } + defer resp.Body.Close() +} + // getEncConfig returns the encoding configuration for the Nibiru chain. func getEncConfig() app.EncodingConfig { return app.MakeEncodingConfig() diff --git a/sai-trading/services/evmtrader/querier.go b/sai-trading/services/evmtrader/querier.go index 0e5b3e53c..acc56ac44 100644 --- a/sai-trading/services/evmtrader/querier.go +++ b/sai-trading/services/evmtrader/querier.go @@ -389,6 +389,82 @@ func (t *EVMTrader) queryERC20Balance(ctx context.Context, erc20ABI abi.ABI, tok return new(big.Int).SetBytes(out), nil } +// queryERC20BalanceFromString queries the ERC20 balance of an account using string addresses +func (t *EVMTrader) queryERC20BalanceFromString(ctx context.Context, erc20ABI abi.ABI, tokenAddr string, account common.Address) (*big.Int, error) { + token := common.HexToAddress(tokenAddr) + return t.queryERC20Balance(ctx, erc20ABI, token, account) +} + +// QueryCollaterals queries the perp contract for all available collaterals +// Tries to list collaterals, and if that's not supported, tries common indices (0-10) +func (t *EVMTrader) QueryCollaterals(ctx context.Context) ([]CollateralInfo, error) { + // Try list_collaterals query (similar to list_markets) + queryMsg := map[string]interface{}{ + "list_collaterals": map[string]interface{}{}, + } + + responseBytes, err := t.queryWasmContract(ctx, t.addrs.PerpAddress, queryMsg) + if err == nil { + // Parse JSON response - list_collaterals might return an array of collateral indices + var collateralIndices []string + if err := json.Unmarshal(responseBytes, &collateralIndices); err != nil { + // Try with data wrapper + var wrapped struct { + Data []string `json:"data"` + } + if err2 := json.Unmarshal(responseBytes, &wrapped); err2 == nil { + collateralIndices = wrapped.Data + } + } + + if len(collateralIndices) > 0 { + // Query each collateral individually to get full details + var collaterals []CollateralInfo + for _, collateralIndexStr := range collateralIndices { + // Extract collateral index from string (e.g., "TokenIndex(0)" or just "0") + var collateralIndex uint64 + if _, err := fmt.Sscanf(collateralIndexStr, "TokenIndex(%d)", &collateralIndex); err != nil { + // Try parsing as just a number + if _, err := fmt.Sscanf(collateralIndexStr, "%d", &collateralIndex); err != nil { + continue // Skip invalid indices + } + } + + // Query individual collateral details + denom, err := t.queryCollateralDenom(ctx, collateralIndex) + if err != nil { + continue + } + collaterals = append(collaterals, CollateralInfo{ + Index: collateralIndex, + Denom: denom, + }) + } + return collaterals, nil + } + } + + // Fallback: try common indices (0-10) to find available collaterals + var collaterals []CollateralInfo + for i := uint64(0); i <= 10; i++ { + denom, err := t.queryCollateralDenom(ctx, i) + if err == nil && denom != "" { + collaterals = append(collaterals, CollateralInfo{ + Index: i, + Denom: denom, + }) + } + } + + return collaterals, nil +} + +// CollateralInfo contains information about a collateral token +type CollateralInfo struct { + Index uint64 + Denom string +} + // queryCollateralDenom queries the perp contract for the denomination of a collateral token by index func (t *EVMTrader) queryCollateralDenom(ctx context.Context, collateralIndex uint64) (string, error) { queryMsg := map[string]interface{}{ diff --git a/sai-trading/services/evmtrader/trade_builder.go b/sai-trading/services/evmtrader/trade_builder.go index 56208aa00..5b902448c 100644 --- a/sai-trading/services/evmtrader/trade_builder.go +++ b/sai-trading/services/evmtrader/trade_builder.go @@ -31,12 +31,13 @@ func (t *EVMTrader) buildOpenTradeMessage(params *OpenTradeParams) ([]byte, erro return nil, fmt.Errorf("open_price is required") } // For limit/stop orders, open_price must be non-zero (trigger price) - if (params.TradeType == "limit" || params.TradeType == "stop") && *params.OpenPrice == 0 { + if isLimitOrStopOrder(params.TradeType) && *params.OpenPrice == 0 { return nil, fmt.Errorf("open_price must be non-zero for %s orders", params.TradeType) } openTradeMsgData["open_price"] = strconv.FormatFloat(*params.OpenPrice, 'f', -1, 64) - // Only set TP/SL if provided + // Only set TP/SL if explicitly provided by user + // Note: The contract may set its own default TP/SL values for limit/stop orders if params.TP != nil { openTradeMsgData["tp"] = strconv.FormatFloat(*params.TP, 'f', -1, 64) } @@ -81,7 +82,7 @@ func (t *EVMTrader) OpenTradeFromJSON(ctx context.Context, jsonPath string) erro } // If open_price not provided for market orders, fetch from oracle - if params.OpenPrice == nil && params.TradeType == "trade" { + if params.OpenPrice == nil && params.TradeType == TradeTypeMarket { price, err := t.fetchMarketPriceForIndex(ctx, params.MarketIndex) if err != nil { return fmt.Errorf("fetch market price for market %d: %w", params.MarketIndex, err) @@ -105,9 +106,9 @@ func (t *EVMTrader) parseTradeParamsFromJSON(data map[string]interface{}) (*Open // Initialize params with defaults params := &OpenTradeParams{ SlippageP: defaultSlippagePercent, - Long: true, // Default to long position - Leverage: 1, // Default leverage is 1x - TradeType: "trade", // Default to market trade + Long: true, // Default to long position + Leverage: 1, // Default leverage is 1x + TradeType: TradeTypeMarket, // Default to market trade } // Parse collateral_amount (required) @@ -125,15 +126,10 @@ func (t *EVMTrader) parseTradeParamsFromJSON(data map[string]interface{}) (*Open params.MarketIndex = idx // Parse leverage (optional, defaults to 1) - if levStr, ok := data["leverage"].(string); ok && levStr != "" { - leverage, err := strconv.ParseUint(levStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("parse leverage: %w", err) - } - if leverage == 0 { - return nil, fmt.Errorf("leverage must be greater than 0, got: %d", leverage) - } - params.Leverage = leverage + if leverage, err := parseOptionalPositiveUint64(data, "leverage"); err != nil { + return nil, err + } else if leverage != nil { + params.Leverage = *leverage } // else: use default leverage of 1 @@ -170,7 +166,7 @@ func (t *EVMTrader) parseTradeParamsFromJSON(data map[string]interface{}) (*Open } // Parse TP/SL (only for limit/stop orders) - if params.TradeType != "trade" { + if params.TradeType != TradeTypeMarket { if err := t.parseTakeProfitStopLoss(data, params); err != nil { return nil, err } @@ -235,7 +231,7 @@ func validateTradeParams(params *OpenTradeParams) error { } // For limit/stop orders, open_price must be non-zero (trigger price) - if (params.TradeType == "limit" || params.TradeType == "stop") && *params.OpenPrice == 0 { + if isLimitOrStopOrder(params.TradeType) && *params.OpenPrice == 0 { return fmt.Errorf("open_price must be non-zero for %s orders (trigger price)", params.TradeType) } @@ -293,7 +289,7 @@ func (t *EVMTrader) parseOpenPrice(data map[string]interface{}, params *OpenTrad priceStr, ok := data["open_price"].(string) if !ok || priceStr == "" { // For limit/stop orders, open_price is REQUIRED (trigger price) - if params.TradeType == "limit" || params.TradeType == "stop" { + if isLimitOrStopOrder(params.TradeType) { return fmt.Errorf("open_price is required for %s orders (trigger price)", params.TradeType) } // For market orders, open_price is OPTIONAL - will be fetched from oracle @@ -307,7 +303,7 @@ func (t *EVMTrader) parseOpenPrice(data map[string]interface{}, params *OpenTrad } // For limit/stop orders, open_price must be non-zero (trigger price) - if (params.TradeType == "limit" || params.TradeType == "stop") && price == 0 { + if isLimitOrStopOrder(params.TradeType) && price == 0 { return fmt.Errorf("open_price must be non-zero for %s orders (trigger price)", params.TradeType) } @@ -357,14 +353,44 @@ func parsePositiveFloat(value, fieldName string) (float64, error) { return parsed, nil } -// buildCloseTradeMessage builds the close_trade_market message from trade index +// parsePositiveUint64 parses a uint64 value from a string and validates it's greater than zero. +func parsePositiveUint64(value, fieldName string) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", fieldName, err) + } + + if parsed == 0 { + return 0, fmt.Errorf("%s must be greater than 0, got: %d", fieldName, parsed) + } + + return parsed, nil +} + +// parseOptionalPositiveUint64 parses an optional uint64 field from JSON data. +// Returns nil if the field doesn't exist or is empty, allowing caller to use default value. +func parseOptionalPositiveUint64(data map[string]interface{}, fieldName string) (*uint64, error) { + valStr, ok := data[fieldName].(string) + if !ok || valStr == "" { + return nil, nil // Field not present or empty, use default + } + + val, err := parsePositiveUint64(valStr, fieldName) + if err != nil { + return nil, err + } + + return &val, nil +} + +// buildCloseTradeMessage builds the close_trade message from trade index func (t *EVMTrader) buildCloseTradeMessage(tradeIndex uint64) ([]byte, error) { closeTradeMsgData := map[string]interface{}{ "trade_index": fmt.Sprintf("UserTradeIndex(%d)", tradeIndex), } closeTradeMsg := map[string]interface{}{ - "close_trade_market": closeTradeMsgData, + "close_trade": closeTradeMsgData, } return json.Marshal(closeTradeMsg) diff --git a/sai-trading/services/evmtrader/trade_params.go b/sai-trading/services/evmtrader/trade_params.go index 189628637..115dcba77 100644 --- a/sai-trading/services/evmtrader/trade_params.go +++ b/sai-trading/services/evmtrader/trade_params.go @@ -8,10 +8,6 @@ import ( ) const ( - // Price adjustment percentages for limit orders - limitOrderPriceAdjustmentUp = 1.1 // 10% above for long positions - limitOrderPriceAdjustmentDown = 0.9 // 10% below for short positions - // Default slippage percentage defaultSlippagePercent = "1" ) @@ -19,27 +15,40 @@ const ( // prepareTradeFromConfig prepares trade parameters from the trader's config. // This is the main orchestration function that coordinates the trade preparation process. func (t *EVMTrader) prepareTradeFromConfig(ctx context.Context, balance *big.Int) (*OpenTradeParams, error) { - // Step 1: Determine trade amount (validates balance internally) - tradeAmt, err := t.determineTradeAmount(balance) - if err != nil { - return nil, err - } - if tradeAmt == nil { - // Insufficient balance - not an error, just skip this trade - return nil, nil + // Step 1: Determine collateral index + // If user provided a collateral index, use it. Otherwise, default to the market's quote token index. + collateralIndex := t.cfg.CollateralIndex + if collateralIndex == 0 { + market, err := t.queryMarket(ctx, t.cfg.MarketIndex) + if err != nil { + return nil, fmt.Errorf("query market for collateral index (market=%d): %w", t.cfg.MarketIndex, err) + } + if market.QuoteToken == nil { + return nil, fmt.Errorf("market %d has no quote token to use as default collateral index", t.cfg.MarketIndex) + } + collateralIndex = *market.QuoteToken + t.log("Using quote token index as default collateral index", "market_index", t.cfg.MarketIndex, "collateral_index", collateralIndex) + } else { + t.log("Using user-provided collateral index", "market_index", t.cfg.MarketIndex, "collateral_index", collateralIndex) } - // Step 2: Determine trade parameters (pure logic, no I/O) + // Step 2: Determine trade amount (validates balance internally) + // TODO: validate balance after + tradeAmt := new(big.Int).SetUint64(t.cfg.TradeSize) + + // Step 3: Determine trade parameters (pure logic, no I/O) leverage := t.determineLeverage() isLong := t.determineDirection() tradeType := t.determineTradeType() - // Step 3: Determine open_price + // Step 4: Determine open_price // - If set in config (from CLI --open-price flag), use that // - Otherwise, fetch from oracle var openPrice float64 + var userProvidedPrice bool if t.cfg.OpenPrice != nil { openPrice = *t.cfg.OpenPrice + userProvidedPrice = true t.log("Using open_price from config", "price", openPrice) } else { // Fetch market price from oracle (I/O operation) @@ -52,45 +61,39 @@ func (t *EVMTrader) prepareTradeFromConfig(ctx context.Context, balance *big.Int return nil, nil } openPrice = price + userProvidedPrice = false t.log("Fetched open_price from oracle", "price", openPrice) } - // Step 4: Adjust price for limit orders - isLimitOrder := (tradeType == "limit" || tradeType == "stop") - adjustedPrice := t.adjustPriceForLimitOrder(openPrice, isLong, isLimitOrder) + // Step 5: Adjust price for limit orders + // Only adjust if price was fetched from oracle, not user-provided + isLimitOrder := isLimitOrStopOrder(tradeType) + adjustedPrice := t.adjustPriceForLimitOrder(openPrice, isLong, isLimitOrder, userProvidedPrice) // Validate adjusted price is non-zero for limit/stop orders (required by specification) if isLimitOrder && adjustedPrice == 0 { return nil, fmt.Errorf("adjusted open_price cannot be zero for %s orders (trigger price required)", tradeType) } - // Step 5: Calculate TP/SL for limit orders - var tp, sl *float64 - if isLimitOrder { - tpVal, slVal := t.calculateTPSL(adjustedPrice, isLong) - tp = &tpVal - sl = &slVal - } - // Step 6: Log and return - t.logTradePreparation(tradeType, isLong, leverage, tradeAmt, adjustedPrice, openPrice, tp, sl) + t.logTradePreparation(tradeType, isLong, leverage, tradeAmt, adjustedPrice, openPrice, nil, nil) return &OpenTradeParams{ MarketIndex: t.cfg.MarketIndex, Leverage: leverage, Long: isLong, - CollateralIndex: t.cfg.CollateralIndex, + CollateralIndex: collateralIndex, TradeType: tradeType, OpenPrice: &adjustedPrice, - TP: tp, - SL: sl, + TP: nil, // Only set if explicitly provided + SL: nil, // Only set if explicitly provided SlippageP: defaultSlippagePercent, CollateralAmt: tradeAmt, }, nil } -// determineTradeAmount calculates the trade amount based on config and available balance. -// Returns nil if balance is insufficient (not an error condition). +// determineTradeAmount calculates the trade amount based on user-provided config only. +// Returns nil if balance is insufficient or no trade size is configured (not an error condition). func (t *EVMTrader) determineTradeAmount(balance *big.Int) (*big.Int, error) { var tradeAmt *big.Int @@ -102,10 +105,10 @@ func (t *EVMTrader) determineTradeAmount(balance *big.Int) (*big.Int, error) { return nil, nil } } else { - // Use random amount within configured range - tradeAmt = t.calculateRandomTradeAmount(balance) + // Use user-provided TradeSizeMin or TradeSizeMax only (no fallback to balance) + tradeAmt = t.calculateDeterministicTradeAmount(balance) if tradeAmt == nil { - t.log("Insufficient ERC20 balance for trade", "balance", balance.String()) + t.log("Insufficient ERC20 balance for trade or no trade size configured", "balance", balance.String()) return nil, nil } } @@ -114,28 +117,23 @@ func (t *EVMTrader) determineTradeAmount(balance *big.Int) (*big.Int, error) { } // determineLeverage returns the leverage to use for the trade. -// Uses config value if set, otherwise random within configured range. -// Ensures leverage > 0 as required by specification. +// Uses config value if set, otherwise defaults to 1. func (t *EVMTrader) determineLeverage() uint64 { if t.cfg.Leverage > 0 { return t.cfg.Leverage } - leverage := t.calculateRandomLeverage() - // Ensure leverage is at least 1 (required by specification) - if leverage == 0 { - return 1 - } - return leverage + // Default to 1x leverage if not specified + return 1 } // determineDirection returns the trade direction (long or short). -// Uses config value if set, otherwise random using cryptographically secure randomness. +// Uses config value if set, otherwise defaults to long (true). func (t *EVMTrader) determineDirection() bool { if t.cfg.Long != nil { return *t.cfg.Long } - // Use crypto/rand for unpredictable trade direction - return secureRandomBool() + // Default to long if not specified + return true } // determineTradeType returns the trade type (trade, limit, or stop). @@ -162,7 +160,7 @@ func (t *EVMTrader) determineTradeType() string { // For market orders, it fetches the exchange rate between base and quote tokens. // For limit/stop orders, it fetches the collateral token price. func (t *EVMTrader) fetchMarketPrice(ctx context.Context, tradeType string) (float64, error) { - if tradeType == "trade" { + if tradeType == TradeTypeMarket { // For market orders, get the exchange rate (base per quote) return t.fetchExchangeRateForMarket(ctx) } @@ -207,19 +205,10 @@ func (t *EVMTrader) fetchMarketPriceForIndex(ctx context.Context, marketIndex ui return rate, nil } -// adjustPriceForLimitOrder adjusts the price for limit orders. -// For long positions, increases price by 10% (buy limit above current price). -// For short positions, decreases price by 10% (sell limit below current price). -// For market orders, returns the price unchanged. -func (t *EVMTrader) adjustPriceForLimitOrder(price float64, isLong, isLimitOrder bool) float64 { - if !isLimitOrder { - return price - } - - if isLong { - return price * limitOrderPriceAdjustmentUp - } - return price * limitOrderPriceAdjustmentDown +// adjustPriceForLimitOrder returns the price unchanged. +// All prices (user-provided and oracle-fetched) are used as-is without any adjustment. +func (t *EVMTrader) adjustPriceForLimitOrder(price float64, isLong, isLimitOrder, userProvided bool) float64 { + return price } // logTradePreparation logs the prepared trade parameters. @@ -227,7 +216,7 @@ func (t *EVMTrader) logTradePreparation(tradeType string, isLong bool, leverage tradeAmt *big.Int, adjustedPrice, oraclePrice float64, tp, sl *float64) { whatTraderOpens := "position" - if tradeType == "limit" || tradeType == "stop" { + if isLimitOrStopOrder(tradeType) { whatTraderOpens = "limit order" } @@ -254,71 +243,39 @@ func secureRandomBool() bool { return b[0]&1 == 1 } -// calculateRandomTradeAmount calculates a random trade amount within configured range. -// Uses cryptographically secure randomness for unpredictable trade amounts. -func (t *EVMTrader) calculateRandomTradeAmount(balance *big.Int) *big.Int { - if t.cfg.TradeSizeMax <= t.cfg.TradeSizeMin { - // Use min if max not set +// calculateDeterministicTradeAmount calculates a deterministic trade amount based on user-provided config only. +// Uses TradeSizeMin if set, otherwise TradeSizeMax. Returns nil if balance is insufficient or no size is configured. +func (t *EVMTrader) calculateDeterministicTradeAmount(balance *big.Int) *big.Int { + // Prefer TradeSizeMin if set + if t.cfg.TradeSizeMin > 0 { amt := new(big.Int).SetUint64(t.cfg.TradeSizeMin) if balance.Cmp(amt) < 0 { + // Insufficient balance - return nil (don't use available balance) return nil } + // If TradeSizeMax is set and larger than min, use TradeSizeMax (if balance allows) + if t.cfg.TradeSizeMax > t.cfg.TradeSizeMin { + maxAmt := new(big.Int).SetUint64(t.cfg.TradeSizeMax) + if balance.Cmp(maxAmt) < 0 { + // Insufficient balance for max - return nil (don't use available balance) + return nil + } + // Use max if balance is sufficient + return maxAmt + } return amt } - // Random amount between min and max using crypto/rand - rangeSize := t.cfg.TradeSizeMax - t.cfg.TradeSizeMin - randomOffset := secureRandomUint64(rangeSize + 1) - tradeAmt := t.cfg.TradeSizeMin + randomOffset - - amt := new(big.Int).SetUint64(tradeAmt) - if balance.Cmp(amt) < 0 { - // If balance is less than min, try to use what we have - if balance.Cmp(big.NewInt(0)) > 0 { - return balance + // If TradeSizeMin not set, try TradeSizeMax + if t.cfg.TradeSizeMax > 0 { + amt := new(big.Int).SetUint64(t.cfg.TradeSizeMax) + if balance.Cmp(amt) < 0 { + // Insufficient balance - return nil (don't use available balance) + return nil } - return nil - } - return amt -} - -// calculateRandomLeverage calculates a random leverage within configured range. -// Uses cryptographically secure randomness for unpredictable leverage selection. -func (t *EVMTrader) calculateRandomLeverage() uint64 { - if t.cfg.LeverageMax <= t.cfg.LeverageMin { - return t.cfg.LeverageMin - } - rangeSize := t.cfg.LeverageMax - t.cfg.LeverageMin - randomOffset := secureRandomUint64(rangeSize + 1) - return t.cfg.LeverageMin + randomOffset -} - -// secureRandomUint64 returns a cryptographically secure random uint64 in the range [0, max). -func secureRandomUint64(max uint64) uint64 { - if max == 0 { - return 0 + return amt } - // Generate random big.Int - maxBig := new(big.Int).SetUint64(max) - n, err := rand.Int(rand.Reader, maxBig) - if err != nil { - // Fallback to 0 on error (should never happen) - return 0 - } - return n.Uint64() -} - -// calculateTPSL calculates take profit and stop loss based on open price and direction -func (t *EVMTrader) calculateTPSL(openPrice float64, isLong bool) (tp, sl float64) { - if isLong { - // Long: TP above, SL below - tp = openPrice * 1.5 - sl = openPrice / 1.5 - } else { - // Short: TP below, SL above - tp = openPrice / 1.5 - sl = openPrice * 1.5 - } - return tp, sl + // If neither min nor max is set, return nil (no user input) + return nil } diff --git a/sai-trading/services/evmtrader/trade_types.go b/sai-trading/services/evmtrader/trade_types.go new file mode 100644 index 000000000..ef3734e72 --- /dev/null +++ b/sai-trading/services/evmtrader/trade_types.go @@ -0,0 +1,26 @@ +package evmtrader + +// Trade type constants +const ( + TradeTypeMarket = "trade" + TradeTypeLimit = "limit" + TradeTypeStop = "stop" +) + +// IsLimitOrStopOrder returns true if the trade type is a limit or stop order. +func IsLimitOrStopOrder(tradeType string) bool { + return tradeType == TradeTypeLimit || tradeType == TradeTypeStop +} + +// IsValidTradeType returns true if the trade type is valid. +func IsValidTradeType(tradeType string) bool { + return tradeType == TradeTypeMarket || + tradeType == TradeTypeLimit || + tradeType == TradeTypeStop +} + +// isLimitOrStopOrder is an internal helper that calls IsLimitOrStopOrder. +// This is provided for backward compatibility with internal code. +func isLimitOrStopOrder(tradeType string) bool { + return IsLimitOrStopOrder(tradeType) +} diff --git a/sai-trading/services/evmtrader/transaction.go b/sai-trading/services/evmtrader/transaction.go index 107172907..79b4686a3 100644 --- a/sai-trading/services/evmtrader/transaction.go +++ b/sai-trading/services/evmtrader/transaction.go @@ -136,6 +136,7 @@ func (t *EVMTrader) sendOpenTradeTransaction(ctx context.Context, chainID *big.I // Query the correct denomination for the collateral index collateralDenom, err := t.queryCollateralDenom(ctx, collateralIndex) if err != nil { + // Provide helpful error message suggesting common alternatives return nil, fmt.Errorf("query collateral denom for index %d: %w", collateralIndex, err) } @@ -155,13 +156,13 @@ func (t *EVMTrader) sendOpenTradeTransaction(ctx context.Context, chainID *big.I return t.sendEVMTransaction(ctx, wasmPrecompileAddr, big.NewInt(0), data, chainID) } -// sendCloseTradeTransaction sends the close_trade_market transaction +// sendCloseTradeTransaction sends the close_trade transaction func (t *EVMTrader) sendCloseTradeTransaction(ctx context.Context, chainID *big.Int, msgBytes []byte) (*sdk.TxResponse, error) { // Build WASM execute call wasmABI := getWasmPrecompileABI() wasmPrecompileAddr := precompile.PrecompileAddr_Wasm - // No funds needed for close_trade_market + // No funds needed for close_trade funds := []struct { Denom string Amount *big.Int