From 30e144a0b68d4b0b8e332610316c2691d43871b3 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:10:48 +0000 Subject: [PATCH 1/3] refactor: apply empirically validated comprehension improvements Keep only the code refactors validated in the comprehension autoresearch pass, excluding autoresearch harness/log artifacts. Highlights: - consolidate shared CLI parsing, client, resolver, and fetch helpers - flatten large command handlers and split oversized command files - split large package files in client, exchange, info, and resolver into focused units - remove duplicated command-layer validation already enforced downstream - simplify request/result assembly while preserving behavior Validation: make check --- cmd/account.go | 16 +- cmd/account_approve_agent.go | 26 +- cmd/account_class_transfer.go | 60 +- cmd/account_send_asset.go | 35 +- cmd/account_set_abstraction.go | 36 +- cmd/account_transfer.go | 36 +- cmd/account_withdraw.go | 31 +- cmd/agent.go | 16 +- cmd/agent_bracket.go | 169 ++--- cmd/agent_pnl.go | 298 +++------ cmd/agent_pnl_calc.go | 132 ++++ cmd/agent_snapshot.go | 197 +++--- cmd/config.go | 104 +--- cmd/helpers.go | 201 +++++- cmd/info.go | 16 +- cmd/info_funding.go | 45 +- cmd/info_lookup.go | 553 ++--------------- cmd/info_lookup_aliases.go | 106 ++++ cmd/info_lookup_fetch.go | 204 ++++++ cmd/info_lookup_score.go | 187 ++++++ cmd/info_market.go | 134 ++-- cmd/info_user.go | 119 +--- cmd/order.go | 16 +- cmd/order_batch.go | 198 +++--- cmd/order_cancel.go | 174 ++---- cmd/order_market.go | 112 +--- cmd/order_modify.go | 168 ++--- cmd/order_place.go | 178 ++---- cmd/order_schedule_cancel.go | 57 +- cmd/position.go | 16 +- cmd/position_leverage.go | 18 +- cmd/position_margin.go | 31 +- pkg/client/client.go | 163 +---- pkg/client/exchange_response.go | 114 ++++ pkg/client/weight.go | 17 +- pkg/exchange/builder.go | 86 +-- pkg/exchange/executor.go | 863 +------------------------- pkg/exchange/executor_management.go | 192 ++++++ pkg/exchange/executor_orders.go | 224 +++++++ pkg/exchange/executor_user.go | 236 +++++++ pkg/exchange/executor_user_helpers.go | 133 ++++ pkg/info/responses.go | 534 ---------------- pkg/info/responses_account.go | 175 ++++++ pkg/info/responses_funding.go | 211 +++++++ pkg/info/responses_market.go | 167 +++++ pkg/output/errors.go | 5 +- pkg/resolver/cache.go | 5 +- pkg/resolver/metadata.go | 175 ++++++ pkg/resolver/resolver.go | 417 +------------ pkg/resolver/spot_maps.go | 233 +++++++ pkg/signer/eip712.go | 24 +- pkg/signer/phantom.go | 13 +- pkg/signer/signer.go | 5 +- 53 files changed, 3493 insertions(+), 4188 deletions(-) create mode 100644 cmd/agent_pnl_calc.go create mode 100644 cmd/info_lookup_aliases.go create mode 100644 cmd/info_lookup_fetch.go create mode 100644 cmd/info_lookup_score.go create mode 100644 pkg/client/exchange_response.go create mode 100644 pkg/exchange/executor_management.go create mode 100644 pkg/exchange/executor_orders.go create mode 100644 pkg/exchange/executor_user.go create mode 100644 pkg/exchange/executor_user_helpers.go create mode 100644 pkg/info/responses_account.go create mode 100644 pkg/info/responses_funding.go create mode 100644 pkg/info/responses_market.go create mode 100644 pkg/resolver/metadata.go create mode 100644 pkg/resolver/spot_maps.go diff --git a/cmd/account.go b/cmd/account.go index fd26227..557a418 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -3,18 +3,12 @@ package cmd import "github.com/spf13/cobra" func newAccountCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "account", - Short: "Account transfers, withdrawals, and agent management", - Long: `Transfer USDC between spot and perp, withdraw to Arbitrum, manage agent + return newHelpCommandGroup( + "account", + "Account transfers, withdrawals, and agent management", + `Transfer USDC between spot and perp, withdraw to Arbitrum, manage agent wallet approvals, and perform cross-account transfers. Account commands sign with the configured private key via the user-signed path (chain ID 421614).`, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand( newAccountTransferCmd(), newAccountWithdrawCmd(), newAccountClassTransferCmd(), @@ -22,6 +16,4 @@ sign with the configured private key via the user-signed path (chain ID 421614). newAccountApproveAgentCmd(), newAccountSetAbstractionCmd(), ) - - return cmd } diff --git a/cmd/account_approve_agent.go b/cmd/account_approve_agent.go index 10e5bd2..3cd3f93 100644 --- a/cmd/account_approve_agent.go +++ b/cmd/account_approve_agent.go @@ -3,7 +3,6 @@ package cmd import ( "strings" - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -20,21 +19,14 @@ func newAccountApproveAgentCmd() *cobra.Command { Note: Hyperliquid may charge an activation fee when first approving an agent.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag - name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag - revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag - confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag - yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - - if !common.IsHexAddress(agent) { - return output.NewCLIError(output.ErrValidation, "invalid agent address"). - WithDetails("agent", agent) - } + agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag + name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag + revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag agentName := strings.TrimSpace(name) switch { case revoke: - if err := requireConfirm("approve-agent --revoke", confirm || yes, cfg.DryRun); err != nil { + if err := requireConfirm("approve-agent --revoke", confirmationAccepted(cmd), cfg.DryRun); err != nil { return err } agentName = "" @@ -52,11 +44,8 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, return err } - raw, err := exec.ApproveAgent(cmd.Context(), exchange.ApproveAgentInput{ - AgentAddress: strings.ToLower(agent), - AgentName: agentName, - DryRun: cfg.DryRun, - }) + input := exchange.ApproveAgentInput{AgentAddress: agent, AgentName: agentName, DryRun: cfg.DryRun} + raw, err := exec.ApproveAgent(cmd.Context(), input) if err != nil { return err } @@ -70,8 +59,7 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, cmd.Flags().Bool("revoke", false, "revoke agent by setting an empty label") cmd.Flags().Bool("confirm", false, "confirm execution for revoke flow") cmd.Flags().Bool("yes", false, "alias for --confirm") - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired("agent") + mustMarkRequiredFlags(cmd, "agent") return cmd } diff --git a/cmd/account_class_transfer.go b/cmd/account_class_transfer.go index 8f5ed15..fd4e5d6 100644 --- a/cmd/account_class_transfer.go +++ b/cmd/account_class_transfer.go @@ -1,57 +1,13 @@ package cmd -import ( - "github.com/shopspring/decimal" - "github.com/spf13/cobra" - - "github.com/timbrinded/hlgo/pkg/config" - "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" -) +import "github.com/spf13/cobra" func newAccountClassTransferCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "class-transfer", - Short: "Alias of transfer using usdClassTransfer semantics", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg := config.FromContext(cmd.Context()) - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag - toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag - - if toPerp == toSpot { - return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") - } - - amount, err := decimal.NewFromString(amountStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid amount"). - WithDetails("value", amountStr) - } - - exec, err := buildExecutor(cfg) - if err != nil { - return err - } - - raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - DryRun: cfg.DryRun, - }) - if err != nil { - return err - } - - return printResult(cmd, cfg, raw, nil) - }, - } - - cmd.Flags().String("amount", "", "transfer amount") - cmd.Flags().Bool("to-perp", false, "transfer toward perp class") - cmd.Flags().Bool("to-spot", false, "transfer toward spot class") - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired("amount") - - return cmd + return newUSDClassTransferCmd( + "class-transfer", + "Alias of transfer using usdClassTransfer semantics", + "transfer amount", + "transfer toward perp class", + "transfer toward spot class", + ) } diff --git a/cmd/account_send_asset.go b/cmd/account_send_asset.go index 57739a0..8a02922 100644 --- a/cmd/account_send_asset.go +++ b/cmd/account_send_asset.go @@ -1,15 +1,10 @@ package cmd import ( - "strings" - - "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" ) func newAccountSendAssetCmd() *cobra.Command { @@ -21,25 +16,14 @@ func newAccountSendAssetCmd() *cobra.Command { destination, _ := cmd.Flags().GetString("destination") //nolint:errcheck // known flag token, _ := cmd.Flags().GetString("token") //nolint:errcheck // known flag amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag - yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - if err := requireConfirm("send-asset", confirm || yes, cfg.DryRun); err != nil { + if err := requireConfirm("send-asset", confirmationAccepted(cmd), cfg.DryRun); err != nil { return err } - if !common.IsHexAddress(destination) { - return output.NewCLIError(output.ErrValidation, "invalid destination address"). - WithDetails("destination", destination) - } - if strings.TrimSpace(token) == "" { - return output.NewCLIError(output.ErrValidation, "token is required") - } - - amount, err := decimal.NewFromString(amountStr) + amount, err := parseDecimalField("amount", amountStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid amount"). - WithDetails("value", amountStr) + return err } exec, err := buildExecutor(cfg) @@ -47,12 +31,8 @@ func newAccountSendAssetCmd() *cobra.Command { return err } - raw, err := exec.SpotSend(cmd.Context(), exchange.SpotSendInput{ - Destination: strings.ToLower(destination), - Token: token, - Amount: amount, - DryRun: cfg.DryRun, - }) + input := exchange.SpotSendInput{Destination: destination, Token: token, Amount: amount, DryRun: cfg.DryRun} + raw, err := exec.SpotSend(cmd.Context(), input) if err != nil { return err } @@ -67,10 +47,7 @@ func newAccountSendAssetCmd() *cobra.Command { cmd.Flags().Bool("confirm", false, "confirm execution for asset send") cmd.Flags().Bool("yes", false, "alias for --confirm") - for _, required := range []string{"destination", "token", "amount"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "destination", "token", "amount") return cmd } diff --git a/cmd/account_set_abstraction.go b/cmd/account_set_abstraction.go index 367b02e..7b7e7d5 100644 --- a/cmd/account_set_abstraction.go +++ b/cmd/account_set_abstraction.go @@ -1,22 +1,12 @@ package cmd import ( - "strings" - - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" ) -var allowedAbstractions = map[string]struct{}{ - "unifiedAccount": {}, - "portfolioMargin": {}, - "disabled": {}, -} - func newAccountSetAbstractionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set-abstraction", @@ -26,30 +16,13 @@ func newAccountSetAbstractionCmd() *cobra.Command { user, _ := cmd.Flags().GetString("user") //nolint:errcheck // known flag abstraction, _ := cmd.Flags().GetString("abstraction") //nolint:errcheck // known flag - if !common.IsHexAddress(user) { - return output.NewCLIError(output.ErrValidation, "invalid user address"). - WithDetails("user", user) - } - abstraction = strings.TrimSpace(abstraction) - if abstraction == "" { - return output.NewCLIError(output.ErrValidation, "abstraction is required") - } - if _, ok := allowedAbstractions[abstraction]; !ok { - return output.NewCLIError(output.ErrValidation, "unsupported abstraction value"). - WithDetails("value", abstraction). - WithDetails("allowed", []string{"unifiedAccount", "portfolioMargin", "disabled"}) - } - exec, err := buildExecutor(cfg) if err != nil { return err } - raw, err := exec.UserSetAbstraction(cmd.Context(), exchange.UserSetAbstractionInput{ - User: strings.ToLower(user), - Abstraction: abstraction, - DryRun: cfg.DryRun, - }) + input := exchange.UserSetAbstractionInput{User: user, Abstraction: abstraction, DryRun: cfg.DryRun} + raw, err := exec.UserSetAbstraction(cmd.Context(), input) if err != nil { return err } @@ -61,10 +34,7 @@ func newAccountSetAbstractionCmd() *cobra.Command { cmd.Flags().String("user", "", "user address") cmd.Flags().String("abstraction", "", "abstraction string") - for _, required := range []string{"user", "abstraction"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "user", "abstraction") return cmd } diff --git a/cmd/account_transfer.go b/cmd/account_transfer.go index 873882c..cda52ec 100644 --- a/cmd/account_transfer.go +++ b/cmd/account_transfer.go @@ -1,7 +1,6 @@ package cmd import ( - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -10,9 +9,19 @@ import ( ) func newAccountTransferCmd() *cobra.Command { + return newUSDClassTransferCmd( + "transfer", + "Transfer USDC between spot and perp classes", + "USDC amount", + "transfer from spot to perp", + "transfer from perp to spot", + ) +} + +func newUSDClassTransferCmd(use, short, amountHelp, toPerpHelp, toSpotHelp string) *cobra.Command { cmd := &cobra.Command{ - Use: "transfer", - Short: "Transfer USDC between spot and perp classes", + Use: use, + Short: short, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag @@ -23,10 +32,9 @@ func newAccountTransferCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") } - amount, err := decimal.NewFromString(amountStr) + amount, err := parseDecimalField("amount", amountStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid amount"). - WithDetails("value", amountStr) + return err } exec, err := buildExecutor(cfg) @@ -34,11 +42,8 @@ func newAccountTransferCmd() *cobra.Command { return err } - raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - DryRun: cfg.DryRun, - }) + input := exchange.USDClassTransferInput{Amount: amount, ToPerp: toPerp, DryRun: cfg.DryRun} + raw, err := exec.USDClassTransfer(cmd.Context(), input) if err != nil { return err } @@ -47,11 +52,10 @@ func newAccountTransferCmd() *cobra.Command { }, } - cmd.Flags().String("amount", "", "USDC amount") - cmd.Flags().Bool("to-perp", false, "transfer from spot to perp") - cmd.Flags().Bool("to-spot", false, "transfer from perp to spot") - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired("amount") + cmd.Flags().String("amount", "", amountHelp) + cmd.Flags().Bool("to-perp", false, toPerpHelp) + cmd.Flags().Bool("to-spot", false, toSpotHelp) + mustMarkRequiredFlags(cmd, "amount") return cmd } diff --git a/cmd/account_withdraw.go b/cmd/account_withdraw.go index 9ad826c..43729a7 100644 --- a/cmd/account_withdraw.go +++ b/cmd/account_withdraw.go @@ -1,15 +1,10 @@ package cmd import ( - "strings" - - "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" ) func newAccountWithdrawCmd() *cobra.Command { @@ -20,22 +15,14 @@ func newAccountWithdrawCmd() *cobra.Command { cfg := config.FromContext(cmd.Context()) destination, _ := cmd.Flags().GetString("destination") //nolint:errcheck // known flag amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag - yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - if err := requireConfirm("withdraw", confirm || yes, cfg.DryRun); err != nil { + if err := requireConfirm("withdraw", confirmationAccepted(cmd), cfg.DryRun); err != nil { return err } - if !common.IsHexAddress(destination) { - return output.NewCLIError(output.ErrValidation, "invalid destination address"). - WithDetails("destination", destination) - } - - amount, err := decimal.NewFromString(amountStr) + amount, err := parseDecimalField("amount", amountStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid amount"). - WithDetails("value", amountStr) + return err } exec, err := buildExecutor(cfg) @@ -43,11 +30,8 @@ func newAccountWithdrawCmd() *cobra.Command { return err } - raw, err := exec.Withdraw3(cmd.Context(), exchange.Withdraw3Input{ - Destination: strings.ToLower(destination), - Amount: amount, - DryRun: cfg.DryRun, - }) + input := exchange.Withdraw3Input{Destination: destination, Amount: amount, DryRun: cfg.DryRun} + raw, err := exec.Withdraw3(cmd.Context(), input) if err != nil { return err } @@ -61,10 +45,7 @@ func newAccountWithdrawCmd() *cobra.Command { cmd.Flags().Bool("confirm", false, "confirm execution for withdrawal") cmd.Flags().Bool("yes", false, "alias for --confirm") - for _, required := range []string{"destination", "amount"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "destination", "amount") return cmd } diff --git a/cmd/agent.go b/cmd/agent.go index 61f6f37..37bf42f 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -3,21 +3,13 @@ package cmd import "github.com/spf13/cobra" func newAgentCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "agent", - Short: "High-level agent workflows", - Long: `Compose multiple info and exchange operations into agent-native workflows + return newHelpCommandGroup( + "agent", + "High-level agent workflows", + `Compose multiple info and exchange operations into agent-native workflows for state snapshots, PnL analysis, and bracket order execution.`, - RunE: func(cmd *cobra.Command, _ []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand( newAgentSnapshotCmd(), newAgentPnlCmd(), newAgentBracketCmd(), ) - - return cmd } diff --git a/cmd/agent_bracket.go b/cmd/agent_bracket.go index e40b8ef..1f656e9 100644 --- a/cmd/agent_bracket.go +++ b/cmd/agent_bracket.go @@ -1,14 +1,10 @@ package cmd import ( - "strings" - - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" - "github.com/timbrinded/hlgo/pkg/exchange" "github.com/timbrinded/hlgo/pkg/output" ) @@ -18,113 +14,22 @@ func newAgentBracketCmd() *cobra.Command { Short: "Place entry + TP + SL in one grouped order action", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag - sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag - tpStr, _ := cmd.Flags().GetString("tp") //nolint:errcheck // known flag - slStr, _ := cmd.Flags().GetString("sl") //nolint:errcheck // known flag - tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag - cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag - builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag - - side = strings.ToLower(side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", side) - } - - price, err := decimal.NewFromString(priceStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid price"). - WithDetails("value", priceStr) - } - size, err := decimal.NewFromString(sizeStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid size"). - WithDetails("value", sizeStr) - } - tp, err := decimal.NewFromString(tpStr) + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + sideFlag, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag + sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag + tpStr, _ := cmd.Flags().GetString("tp") //nolint:errcheck // known flag + slStr, _ := cmd.Flags().GetString("sl") //nolint:errcheck // known flag + tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag + cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag + + input, err := buildPlaceOrderInput(cmd, coin, sideFlag, priceStr, sizeStr, tifFlag, false, cloidStr, cfg.DryRun) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid tp"). - WithDetails("value", tpStr) + return err } - sl, err := decimal.NewFromString(slStr) + input.TpTrigger, input.SlTrigger, err = parseBracketTriggers(input.Side, input.Price, tpStr, slStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid sl"). - WithDetails("value", slStr) - } - - wireTif, ok := tifMap[strings.ToLower(tifFlag)] - if !ok { - return output.NewCLIError(output.ErrValidation, "invalid tif: "+tifFlag). - WithDetails("value", tifFlag). - WithDetails("valid", "gtc, ioc, alo") - } - - if side == "buy" { - if !tp.GreaterThan(price) { - return output.NewCLIError(output.ErrValidation, "for buy brackets, tp must be greater than entry price"). - WithDetails("price", price.String()). - WithDetails("tp", tp.String()) - } - if !sl.LessThan(price) { - return output.NewCLIError(output.ErrValidation, "for buy brackets, sl must be less than entry price"). - WithDetails("price", price.String()). - WithDetails("sl", sl.String()) - } - } else { - if !tp.LessThan(price) { - return output.NewCLIError(output.ErrValidation, "for sell brackets, tp must be less than entry price"). - WithDetails("price", price.String()). - WithDetails("tp", tp.String()) - } - if !sl.GreaterThan(price) { - return output.NewCLIError(output.ErrValidation, "for sell brackets, sl must be greater than entry price"). - WithDetails("price", price.String()). - WithDetails("sl", sl.String()) - } - } - - var cloid *string - if cloidStr != "" { - cloid = &cloidStr - } - - changedBuilder := cmd.Flags().Changed("builder") - changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") - if changedBuilder != changedBuilderFee { - return output.NewCLIError(output.ErrValidation, "--builder and --builder-fee-tenths-bp must be provided together") - } - - var builder *exchange.BuilderInfo - if changedBuilder { - if !common.IsHexAddress(builderAddr) { - return output.NewCLIError(output.ErrValidation, "invalid builder address"). - WithDetails("builder", builderAddr) - } - if builderFeeTenthsBp < 0 { - return output.NewCLIError(output.ErrValidation, "builder fee must be non-negative"). - WithDetails("builder_fee_tenths_bp", builderFeeTenthsBp) - } - builder = &exchange.BuilderInfo{ - B: strings.ToLower(builderAddr), - F: builderFeeTenthsBp, - } - } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms + return err } exec, err := buildExecutor(cfg) @@ -132,21 +37,7 @@ func newAgentBracketCmd() *cobra.Command { return err } - tpTrigger := tp.String() - slTrigger := sl.String() - result, err := exec.PlaceOrder(cmd.Context(), exchange.PlaceOrderInput{ - Coin: coin, - Side: side, - Price: price, - Size: size, - Tif: wireTif, - Cloid: cloid, - TpTrigger: &tpTrigger, - SlTrigger: &slTrigger, - Builder: builder, - ExpiresAfter: expiresAfter, - DryRun: cfg.DryRun, - }) + result, err := exec.PlaceOrder(cmd.Context(), input) if err != nil { return err } @@ -167,10 +58,34 @@ func newAgentBracketCmd() *cobra.Command { cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - for _, required := range []string{"coin", "side", "price", "size", "tp", "sl"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "side", "price", "size", "tp", "sl") return cmd } + +func parseBracketTriggers(side string, price decimal.Decimal, tpStr, slStr string) (*string, *string, error) { + tp, err := parseDecimalField("tp", tpStr) + if err != nil { + return nil, nil, err + } + sl, err := parseDecimalField("sl", slStr) + if err != nil { + return nil, nil, err + } + if side == "buy" { + if !tp.GreaterThan(price) { + return nil, nil, output.NewCLIError(output.ErrValidation, "for buy brackets, tp must be greater than entry price").WithDetails("price", price.String()).WithDetails("tp", tp.String()) + } + if !sl.LessThan(price) { + return nil, nil, output.NewCLIError(output.ErrValidation, "for buy brackets, sl must be less than entry price").WithDetails("price", price.String()).WithDetails("sl", sl.String()) + } + } else { + if !tp.LessThan(price) { + return nil, nil, output.NewCLIError(output.ErrValidation, "for sell brackets, tp must be less than entry price").WithDetails("price", price.String()).WithDetails("tp", tp.String()) + } + if !sl.GreaterThan(price) { + return nil, nil, output.NewCLIError(output.ErrValidation, "for sell brackets, sl must be greater than entry price").WithDetails("price", price.String()).WithDetails("sl", sl.String()) + } + } + return stringPointer(tp.String()), stringPointer(sl.String()), nil +} diff --git a/cmd/agent_pnl.go b/cmd/agent_pnl.go index f4e4d95..d8c5ada 100644 --- a/cmd/agent_pnl.go +++ b/cmd/agent_pnl.go @@ -1,8 +1,6 @@ package cmd import ( - "strconv" - "strings" "time" "github.com/shopspring/decimal" @@ -84,7 +82,6 @@ func newAgentPnlCmd() *cobra.Command { Short: "Compute unrealized, realized, and funding PnL", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - address, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag lookbackHours, _ := cmd.Flags().GetInt("lookback-hours") //nolint:errcheck // known flag aggregateByTime, _ := cmd.Flags().GetBool("aggregate-fills") //nolint:errcheck // known flag @@ -93,7 +90,7 @@ func newAgentPnlCmd() *cobra.Command { WithDetails("value", lookbackHours) } - user, err := info.ResolveUserAddress(address, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } @@ -105,129 +102,14 @@ func newAgentPnlCmd() *cobra.Command { if cmd.Flags().Changed("aggregate-fills") { aggregateByTimePtr = &aggregateByTime } - if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(map[string]any{ - "user": user, - "lookback_hours": lookbackHours, - "requests": map[string]any{ - "state": info.ClearinghouseStateRequest{ - Type: "clearinghouseState", - User: user, - Dex: cfg.Dex, - }, - "mids": info.AllMidsRequest{ - Type: "allMids", - Dex: cfg.Dex, - }, - "fills": info.UserFillsRequest{ - Type: "userFillsByTime", - User: user, - StartTime: startTime, - EndTime: endTime, - AggregateByTime: aggregateByTimePtr, - }, - "user_funding": info.UserFundingRequest{ - Type: "userFunding", - User: user, - StartTime: startTime, - EndTime: endTime, - }, - }, - }), nil) + return printResult(cmd, cfg, mustMarshal(agentPnlDryRunPayload(user, cfg.Dex, lookbackHours, startTime, endTime, aggregateByTimePtr)), nil) } - ic := buildInfoClient(cfg) - - stateRaw, err := ic.ClearinghouseState(cmd.Context(), user, cfg.Dex) + result, err := runAgentPnl(cmd, cfg, user, lookbackHours, now, startTime, endTime, aggregateByTimePtr) if err != nil { return err } - state, err := info.ParseStateResult(stateRaw) - if err != nil { - return err - } - - mids := make(info.MidsResult) - if len(state.AssetPositions) > 0 { - midsRaw, err := ic.AllMids(cmd.Context(), cfg.Dex) - if err != nil { - return err - } - mids, err = info.ParseMidsResult(midsRaw) - if err != nil { - return err - } - } - - result := &agentPnlResult{ - Address: user, - LookbackHours: lookbackHours, - Timestamp: now.Format(time.RFC3339), - } - - realizedPnl := decimal.Zero - fillsRaw, fillsErr := ic.UserFillsByTime(cmd.Context(), user, startTime, endTime, aggregateByTimePtr) - if fillsErr != nil { - result.RealizedUnavailable = true - result.Errors = append(result.Errors, toAgentStepError("fills", fillsErr)) - } else { - fills, parseErr := info.ParseFillsResult(fillsRaw) - if parseErr != nil { - result.RealizedUnavailable = true - result.Errors = append(result.Errors, toAgentStepError("fills", parseErr)) - } else { - realizedPnl, result.Errors = addClosedPnl(fills, result.Errors, cfg.Dex) - } - } - - fundingByCoin := make(map[string]decimal.Decimal) - totalFunding := decimal.Zero - userFundingRaw, fundingErr := ic.UserFunding(cmd.Context(), user, startTime, endTime) - if fundingErr != nil { - result.FundingUnavailable = true - result.Errors = append(result.Errors, toAgentStepError("user-funding", fundingErr)) - } else { - funding, parseErr := info.ParseUserFundingResult(userFundingRaw) - if parseErr != nil { - result.FundingUnavailable = true - result.Errors = append(result.Errors, toAgentStepError("user-funding", parseErr)) - } else { - fundingByCoin, totalFunding, result.Errors = aggregateFundingByCoin(funding, result.Errors, cfg.Dex) - } - } - - totalUnrealized := decimal.Zero - result.Positions = make([]agentPositionPnl, 0, len(state.AssetPositions)) - for _, ap := range state.AssetPositions { - pos, stepErr := positionPnl(ap.Position, mids, fundingByCoin) - if stepErr != nil { - result.Errors = append(result.Errors, *stepErr) - continue - } - result.Positions = append(result.Positions, *pos) - - unrealized, parseErr := decimal.NewFromString(pos.UnrealizedPnl) - if parseErr != nil { - return output.NewCLIError(output.ErrAPI, "failed to parse unrealized pnl"). - WithDetails("coin", pos.Coin). - WithDetails("value", pos.UnrealizedPnl) - } - totalUnrealized = totalUnrealized.Add(unrealized) - } - - if len(state.AssetPositions) > 0 && len(result.Positions) == 0 { - return output.NewCLIError(output.ErrAPI, "agent pnl failed: unable to price any open positions"). - WithDetails("errors", result.Errors) - } - - totalPnl := totalUnrealized.Add(realizedPnl).Add(totalFunding) - result.TotalUnrealizedPnl = totalUnrealized.String() - result.RealizedPnl = realizedPnl.String() - result.TotalFundingPnl = totalFunding.String() - result.TotalPnl = totalPnl.String() - result.Partial = result.FundingUnavailable || result.RealizedUnavailable || len(result.Errors) > 0 - return printResult(cmd, cfg, mustMarshal(result), agentPnlTable{result: result}) }, } @@ -238,130 +120,92 @@ func newAgentPnlCmd() *cobra.Command { return cmd } -func positionPnl(position info.Position, mids info.MidsResult, fundingByCoin map[string]decimal.Decimal) (*agentPositionPnl, *agentStepError) { - size, err := decimal.NewFromString(position.Szi) - if err != nil { - stepErr := agentStepError{ - Step: "pnl", - Code: output.ErrAPI, - Error: "invalid position size for " + position.Coin, - } - return nil, &stepErr - } - - entryPx, err := decimal.NewFromString(position.EntryPx) - if err != nil { - stepErr := agentStepError{ - Step: "pnl", - Code: output.ErrAPI, - Error: "invalid entry price for " + position.Coin, - } - return nil, &stepErr - } - - midStr, ok := mids[position.Coin] - if !ok { - stepErr := agentStepError{ - Step: "pnl", - Code: output.ErrAPI, - Error: "missing mid price for " + position.Coin, - } - return nil, &stepErr +func agentPnlDryRunPayload(user, dex string, lookbackHours int, startTime, endTime int64, aggregateByTimePtr *bool) map[string]any { + return map[string]any{ + "user": user, + "lookback_hours": lookbackHours, + "requests": map[string]any{ + "state": info.ClearinghouseStateRequest{Type: "clearinghouseState", User: user, Dex: dex}, + "mids": info.AllMidsRequest{Type: "allMids", Dex: dex}, + "fills": info.UserFillsRequest{Type: "userFillsByTime", User: user, StartTime: startTime, EndTime: endTime, AggregateByTime: aggregateByTimePtr}, + "user_funding": info.UserFundingRequest{Type: "userFunding", User: user, StartTime: startTime, EndTime: endTime}, + }, } +} - midPx, err := decimal.NewFromString(midStr) +func runAgentPnl(cmd *cobra.Command, cfg *config.Config, user string, lookbackHours int, now time.Time, startTime, endTime int64, aggregateByTimePtr *bool) (*agentPnlResult, error) { + ic := buildInfoClient(cfg) + _, state, err := fetchPerpState(cmd.Context(), cfg, user, cfg.Dex) if err != nil { - stepErr := agentStepError{ - Step: "pnl", - Code: output.ErrAPI, - Error: "invalid mid price for " + position.Coin, - } - return nil, &stepErr + return nil, err } - unrealized := midPx.Sub(entryPx).Mul(size) - funding := fundingByCoin[position.Coin] - - return &agentPositionPnl{ - Coin: position.Coin, - Size: position.Szi, - EntryPrice: position.EntryPx, - MidPrice: midStr, - UnrealizedPnl: unrealized.String(), - FundingPnl: funding.String(), - }, nil -} - -func addClosedPnl(fills info.FillsResult, errs []agentStepError, dex string) (decimal.Decimal, []agentStepError) { - total := decimal.Zero - for _, fill := range fills { - if !coinInDexScope(fill.Coin, dex) { - continue - } - - closedPnl := fill.ClosedPnl - if closedPnl == "" { - continue - } - - value, err := decimal.NewFromString(closedPnl) + mids := make(info.MidsResult) + if len(state.AssetPositions) > 0 { + _, mids, err = fetchMids(cmd.Context(), cfg, cfg.Dex) if err != nil { - errs = append(errs, agentStepError{ - Step: "fills", - Code: output.ErrAPI, - Error: "invalid closedPnl for oid " + strconv.FormatInt(fill.Oid, 10), - }) - continue + return nil, err } - total = total.Add(value) } - return total, errs -} - -func aggregateFundingByCoin(funding info.UserFundingResult, errs []agentStepError, dex string) (map[string]decimal.Decimal, decimal.Decimal, []agentStepError) { - byCoin := make(map[string]decimal.Decimal) - total := decimal.Zero - for _, entry := range funding { - coin := entry.Delta.Coin - if !coinInDexScope(coin, dex) { - continue - } - if coin == "" { - coin = "UNKNOWN" - } - if entry.Delta.USDC == "" { + result := &agentPnlResult{Address: user, LookbackHours: lookbackHours, Timestamp: now.Format(time.RFC3339)} + realizedPnl, fundingByCoin, totalFunding := populateAgentPnlAttribution(ic, cmd, cfg, user, startTime, endTime, aggregateByTimePtr, result) + totalUnrealized := decimal.Zero + result.Positions = make([]agentPositionPnl, 0, len(state.AssetPositions)) + for _, ap := range state.AssetPositions { + pos, stepErr := positionPnl(ap.Position, mids, fundingByCoin) + if stepErr != nil { + result.Errors = append(result.Errors, *stepErr) continue } + result.Positions = append(result.Positions, *pos) - value, err := decimal.NewFromString(entry.Delta.USDC) - if err != nil { - errs = append(errs, agentStepError{ - Step: "user-funding", - Code: output.ErrAPI, - Error: "invalid funding usdc for coin " + coin, - }) - continue + unrealized, parseErr := decimal.NewFromString(pos.UnrealizedPnl) + if parseErr != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to parse unrealized pnl"). + WithDetails("coin", pos.Coin). + WithDetails("value", pos.UnrealizedPnl) } - - byCoin[coin] = byCoin[coin].Add(value) - total = total.Add(value) + totalUnrealized = totalUnrealized.Add(unrealized) + } + if len(state.AssetPositions) > 0 && len(result.Positions) == 0 { + return nil, output.NewCLIError(output.ErrAPI, "agent pnl failed: unable to price any open positions"). + WithDetails("errors", result.Errors) } - return byCoin, total, errs + totalPnl := totalUnrealized.Add(realizedPnl).Add(totalFunding) + result.TotalUnrealizedPnl = totalUnrealized.String() + result.RealizedPnl = realizedPnl.String() + result.TotalFundingPnl = totalFunding.String() + result.TotalPnl = totalPnl.String() + result.Partial = result.FundingUnavailable || result.RealizedUnavailable || len(result.Errors) > 0 + return result, nil } -func coinInDexScope(coin, dex string) bool { - scope := strings.TrimSpace(strings.ToLower(dex)) - if scope == "" { - return true +func populateAgentPnlAttribution(ic *info.InfoClient, cmd *cobra.Command, cfg *config.Config, user string, startTime, endTime int64, aggregateByTimePtr *bool, result *agentPnlResult) (decimal.Decimal, map[string]decimal.Decimal, decimal.Decimal) { + realizedPnl := decimal.Zero + fillsRaw, fillsErr := ic.UserFillsByTime(cmd.Context(), user, startTime, endTime, aggregateByTimePtr) + if fillsErr != nil { + result.RealizedUnavailable = true + result.Errors = append(result.Errors, toAgentStepError("fills", fillsErr)) + } else if fills, parseErr := info.ParseFillsResult(fillsRaw); parseErr != nil { + result.RealizedUnavailable = true + result.Errors = append(result.Errors, toAgentStepError("fills", parseErr)) + } else { + realizedPnl, result.Errors = addClosedPnl(fills, result.Errors, cfg.Dex) } - trimmedCoin := strings.TrimSpace(coin) - idx := strings.Index(trimmedCoin, ":") - if idx <= 0 { - return false + fundingByCoin := make(map[string]decimal.Decimal) + totalFunding := decimal.Zero + userFundingRaw, fundingErr := ic.UserFunding(cmd.Context(), user, startTime, endTime) + if fundingErr != nil { + result.FundingUnavailable = true + result.Errors = append(result.Errors, toAgentStepError("user-funding", fundingErr)) + } else if funding, parseErr := info.ParseUserFundingResult(userFundingRaw); parseErr != nil { + result.FundingUnavailable = true + result.Errors = append(result.Errors, toAgentStepError("user-funding", parseErr)) + } else { + fundingByCoin, totalFunding, result.Errors = aggregateFundingByCoin(funding, result.Errors, cfg.Dex) } - coinDex := strings.ToLower(strings.TrimSpace(trimmedCoin[:idx])) - return coinDex == scope + return realizedPnl, fundingByCoin, totalFunding } diff --git a/cmd/agent_pnl_calc.go b/cmd/agent_pnl_calc.go new file mode 100644 index 0000000..1a7f735 --- /dev/null +++ b/cmd/agent_pnl_calc.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "strconv" + "strings" + + "github.com/shopspring/decimal" + + "github.com/timbrinded/hlgo/pkg/info" + "github.com/timbrinded/hlgo/pkg/output" +) + +func positionPnl(position info.Position, mids info.MidsResult, fundingByCoin map[string]decimal.Decimal) (*agentPositionPnl, *agentStepError) { + size, err := decimal.NewFromString(position.Szi) + if err != nil { + stepErr := agentStepError{ + Step: "pnl", + Code: output.ErrAPI, + Error: "invalid position size for " + position.Coin, + } + return nil, &stepErr + } + + entryPx, err := decimal.NewFromString(position.EntryPx) + if err != nil { + stepErr := agentStepError{ + Step: "pnl", + Code: output.ErrAPI, + Error: "invalid entry price for " + position.Coin, + } + return nil, &stepErr + } + + midStr, ok := mids[position.Coin] + if !ok { + stepErr := agentStepError{ + Step: "pnl", + Code: output.ErrAPI, + Error: "missing mid price for " + position.Coin, + } + return nil, &stepErr + } + + midPx, err := decimal.NewFromString(midStr) + if err != nil { + stepErr := agentStepError{ + Step: "pnl", + Code: output.ErrAPI, + Error: "invalid mid price for " + position.Coin, + } + return nil, &stepErr + } + + unrealized := midPx.Sub(entryPx).Mul(size) + funding := fundingByCoin[position.Coin] + + return &agentPositionPnl{Coin: position.Coin, Size: position.Szi, EntryPrice: position.EntryPx, MidPrice: midStr, UnrealizedPnl: unrealized.String(), FundingPnl: funding.String()}, nil +} + +func addClosedPnl(fills info.FillsResult, errs []agentStepError, dex string) (decimal.Decimal, []agentStepError) { + total := decimal.Zero + for _, fill := range fills { + if !coinInDexScope(fill.Coin, dex) { + continue + } + + closedPnl := fill.ClosedPnl + if closedPnl == "" { + continue + } + + value, err := decimal.NewFromString(closedPnl) + if err != nil { + errs = append(errs, agentStepError{ + Step: "fills", + Code: output.ErrAPI, + Error: "invalid closedPnl for oid " + strconv.FormatInt(fill.Oid, 10), + }) + continue + } + total = total.Add(value) + } + return total, errs +} + +func aggregateFundingByCoin(funding info.UserFundingResult, errs []agentStepError, dex string) (map[string]decimal.Decimal, decimal.Decimal, []agentStepError) { + byCoin := make(map[string]decimal.Decimal) + total := decimal.Zero + + for _, entry := range funding { + coin := entry.Delta.Coin + if !coinInDexScope(coin, dex) { + continue + } + if coin == "" { + coin = "UNKNOWN" + } + if entry.Delta.USDC == "" { + continue + } + + value, err := decimal.NewFromString(entry.Delta.USDC) + if err != nil { + errs = append(errs, agentStepError{ + Step: "user-funding", + Code: output.ErrAPI, + Error: "invalid funding usdc for coin " + coin, + }) + continue + } + + byCoin[coin] = byCoin[coin].Add(value) + total = total.Add(value) + } + + return byCoin, total, errs +} + +func coinInDexScope(coin, dex string) bool { + scope := strings.TrimSpace(strings.ToLower(dex)) + if scope == "" { + return true + } + + trimmedCoin := strings.TrimSpace(coin) + idx := strings.Index(trimmedCoin, ":") + if idx <= 0 { + return false + } + coinDex := strings.ToLower(strings.TrimSpace(trimmedCoin[:idx])) + return coinDex == scope +} diff --git a/cmd/agent_snapshot.go b/cmd/agent_snapshot.go index a98840b..dd6959c 100644 --- a/cmd/agent_snapshot.go +++ b/cmd/agent_snapshot.go @@ -68,134 +68,100 @@ func newAgentSnapshotCmd() *cobra.Command { Short: "Aggregate account state into one response", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - address, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(address, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(map[string]any{ - "user": user, - "requests": map[string]any{ - "state": info.ClearinghouseStateRequest{ - Type: "clearinghouseState", - User: user, - Dex: cfg.Dex, - }, - "spot_state": info.SpotClearinghouseStateRequest{ - Type: "spotClearinghouseState", - User: user, - }, - "open_orders": info.FrontendOpenOrdersRequest{ - Type: "frontendOpenOrders", - User: user, - Dex: cfg.Dex, - }, - "fills": info.UserFillsRequest{ - Type: "userFills", - User: user, - }, - "mids": info.AllMidsRequest{ - Type: "allMids", - Dex: cfg.Dex, - }, - }, - }), nil) + return printResult(cmd, cfg, mustMarshal(snapshotDryRunPayload(user, cfg.Dex)), nil) } - ic := buildInfoClient(cfg) - result := &agentSnapshotResult{ - PerpPositions: make([]info.AssetPosition, 0), - OpenOrders: make(info.OpenOrdersResult, 0), - RecentFills: make(info.FillsResult, 0), - Timestamp: time.Now().UTC().Format(time.RFC3339), - } - successCount := 0 - - stateRaw, err := ic.ClearinghouseState(cmd.Context(), user, cfg.Dex) + result, err := runAgentSnapshot(cmd, cfg, user) if err != nil { - result.Errors = append(result.Errors, toAgentStepError("state", err)) - } else { - state, parseErr := info.ParseStateResult(stateRaw) - if parseErr != nil { - result.Errors = append(result.Errors, toAgentStepError("state", parseErr)) - } else { - result.AccountValue = state.MarginSummary.AccountValue - result.PerpPositions = state.AssetPositions - successCount++ - } + return err } + return printResult(cmd, cfg, mustMarshal(result), agentSnapshotTable{result: result}) + }, + } - spotRaw, err := ic.SpotClearinghouseState(cmd.Context(), user) - if err != nil { - result.Errors = append(result.Errors, toAgentStepError("spot-state", err)) - } else { - balances, parseErr := extractSpotBalances(spotRaw) - if parseErr != nil { - result.Errors = append(result.Errors, toAgentStepError("spot-state", parseErr)) - } else { - result.SpotBalances = balances - successCount++ - } - } + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") + return cmd +} - openOrdersRaw, err := ic.FrontendOpenOrders(cmd.Context(), user, cfg.Dex) - if err != nil { - result.Errors = append(result.Errors, toAgentStepError("open-orders", err)) - } else { - orders, parseErr := info.ParseOpenOrdersResult(openOrdersRaw) - if parseErr != nil { - result.Errors = append(result.Errors, toAgentStepError("open-orders", parseErr)) - } else { - result.OpenOrders = orders - successCount++ - } - } +func snapshotDryRunPayload(user, dex string) map[string]any { + return map[string]any{ + "user": user, + "requests": map[string]any{ + "state": info.ClearinghouseStateRequest{Type: "clearinghouseState", User: user, Dex: dex}, + "spot_state": info.SpotClearinghouseStateRequest{Type: "spotClearinghouseState", User: user}, + "open_orders": info.FrontendOpenOrdersRequest{Type: "frontendOpenOrders", User: user, Dex: dex}, + "fills": info.UserFillsRequest{Type: "userFills", User: user}, + "mids": info.AllMidsRequest{Type: "allMids", Dex: dex}, + }, + } +} - fillsRaw, err := ic.UserFills(cmd.Context(), user, nil) - if err != nil { - result.Errors = append(result.Errors, toAgentStepError("fills", err)) - } else { - fills, parseErr := info.ParseFillsResult(fillsRaw) - if parseErr != nil { - result.Errors = append(result.Errors, toAgentStepError("fills", parseErr)) - } else { - if len(fills) > 10 { - fills = fills[:10] - } - result.RecentFills = fills - successCount++ - } - } +func runAgentSnapshot(cmd *cobra.Command, cfg *config.Config, user string) (*agentSnapshotResult, error) { + ic := buildInfoClient(cfg) + result := &agentSnapshotResult{PerpPositions: make([]info.AssetPosition, 0), OpenOrders: make(info.OpenOrdersResult, 0), RecentFills: make(info.FillsResult, 0), Timestamp: time.Now().UTC().Format(time.RFC3339)} + successCount := 0 + + _, state, err := fetchPerpState(cmd.Context(), cfg, user, cfg.Dex) + if err != nil { + result.Errors = append(result.Errors, toAgentStepError("state", err)) + } else { + result.AccountValue = state.MarginSummary.AccountValue + result.PerpPositions = state.AssetPositions + successCount++ + } - midsRaw, err := ic.AllMids(cmd.Context(), cfg.Dex) - if err != nil { - result.Errors = append(result.Errors, toAgentStepError("mids", err)) - } else { - mids, parseErr := info.ParseMidsResult(midsRaw) - if parseErr != nil { - result.Errors = append(result.Errors, toAgentStepError("mids", parseErr)) - } else { - result.MidPrices = mids - successCount++ - } - } + spotRaw, err := ic.SpotClearinghouseState(cmd.Context(), user) + if err != nil { + result.Errors = append(result.Errors, toAgentStepError("spot-state", err)) + } else if balances, parseErr := extractSpotBalances(spotRaw); parseErr != nil { + result.Errors = append(result.Errors, toAgentStepError("spot-state", parseErr)) + } else { + result.SpotBalances = balances + successCount++ + } - if successCount == 0 { - return output.NewCLIError(output.ErrAPI, "agent snapshot failed: all subqueries failed"). - WithDetails("errors", result.Errors) - } + _, orders, err := fetchOpenOrders(cmd, cfg, user, cfg.Dex) + if err != nil { + result.Errors = append(result.Errors, toAgentStepError("open-orders", err)) + } else { + result.OpenOrders = orders + successCount++ + } - result.Partial = len(result.Errors) > 0 + fillsRaw, err := ic.UserFills(cmd.Context(), user, nil) + if err != nil { + result.Errors = append(result.Errors, toAgentStepError("fills", err)) + } else if fills, parseErr := info.ParseFillsResult(fillsRaw); parseErr != nil { + result.Errors = append(result.Errors, toAgentStepError("fills", parseErr)) + } else { + if len(fills) > 10 { + fills = fills[:10] + } + result.RecentFills = fills + successCount++ + } - return printResult(cmd, cfg, mustMarshal(result), agentSnapshotTable{result: result}) - }, + _, mids, err := fetchMids(cmd.Context(), cfg, cfg.Dex) + if err != nil { + result.Errors = append(result.Errors, toAgentStepError("mids", err)) + } else { + result.MidPrices = mids + successCount++ + } + if successCount == 0 { + return nil, output.NewCLIError(output.ErrAPI, "agent snapshot failed: all subqueries failed"). + WithDetails("errors", result.Errors) } - cmd.Flags().String("address", "", "user address (default: derived from configured private key)") - return cmd + result.Partial = len(result.Errors) > 0 + return result, nil } func toAgentStepError(step string, err error) agentStepError { @@ -208,19 +174,10 @@ func toAgentStepError(step string, err error) agentStepError { details[k] = v } } - return agentStepError{ - Step: step, - Code: cliErr.Code, - Error: cliErr.Message, - Details: details, - } + return agentStepError{Step: step, Code: cliErr.Code, Error: cliErr.Message, Details: details} } - return agentStepError{ - Step: step, - Code: output.ErrAPI, - Error: err.Error(), - } + return agentStepError{Step: step, Code: output.ErrAPI, Error: err.Error()} } func extractSpotBalances(raw json.RawMessage) (any, error) { diff --git a/cmd/config.go b/cmd/config.go index 3f95c5f..f245991 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -62,13 +62,9 @@ func resolveConfigPath(flagValue string) (string, error) { } func newConfigInitCmd() *cobra.Command { - var ( - privateKeyEnv string - accountAddress string - defaultDex string - metadataTTL int - force bool - ) + var privateKeyEnv, accountAddress, defaultDex string + var metadataTTL int + var force bool cmd := &cobra.Command{ Use: "init", @@ -82,45 +78,29 @@ an existing config unless --force is passed.`, if err != nil { return err } - - if !force { - if _, err := os.Stat(cfgPath); err == nil { - return fmt.Errorf("config file already exists: %s (use --force to overwrite)", cfgPath) - } + if _, err := os.Stat(cfgPath); !force && err == nil { + return fmt.Errorf("config file already exists: %s (use --force to overwrite)", cfgPath) } - data := configFileData{ + out, err := yaml.Marshal(configFileData{ PrivateKeyEnv: privateKeyEnv, AccountAddress: accountAddress, DefaultDex: defaultDex, MetadataTTL: metadataTTL, - } - - out, err := yaml.Marshal(data) + }) if err != nil { return fmt.Errorf("marshal config: %w", err) - } - - if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { + } else if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { return fmt.Errorf("create config directory: %w", err) - } - - if err := os.WriteFile(cfgPath, out, 0600); err != nil { + } else if err := os.WriteFile(cfgPath, out, 0600); err != nil { return fmt.Errorf("write config file: %w", err) - } - - if privateKeyEnv != "" && os.Getenv(privateKeyEnv) == "" { + } else if privateKeyEnv != "" && os.Getenv(privateKeyEnv) == "" { if _, werr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: environment variable %s is not set\n", privateKeyEnv); werr != nil { return werr } } - result, err := json.Marshal(map[string]string{"path": cfgPath}) - if err != nil { - return fmt.Errorf("marshal result: %w", err) - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(result)) - return err + return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]string{"path": cfgPath}) }, } @@ -130,11 +110,8 @@ an existing config unless --force is passed.`, cmd.Flags().IntVar(&metadataTTL, "metadata-ttl", 300, "metadata cache TTL in seconds") cmd.Flags().BoolVar(&force, "force", false, "overwrite existing config file") - cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if accountAddress == "" { - return nil - } - if !strictEthAddrRegex.MatchString(accountAddress) { + cmd.PreRunE = func(*cobra.Command, []string) error { + if accountAddress != "" && !strictEthAddrRegex.MatchString(accountAddress) { return fmt.Errorf("invalid --account-address: must be 0x-prefixed 40-hex address") } return nil @@ -173,12 +150,9 @@ func newConfigShowCmd() *cobra.Command { result["private_key_preview"] = config.RedactKey(privateKeyVal) } - out, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(out)) - return err + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) }, } } @@ -215,38 +189,11 @@ func newConfigTestCmd() *cobra.Command { privateKeySet := cfg.PrivateKeyEnv != "" && os.Getenv(cfg.PrivateKeyEnv) != "" result["private_key_env_set"] = privateKeySet + result["connectivity"] = configConnectivityStatus(cmd.Context(), cfg) - // Test connectivity by fetching mid prices. - ic := buildInfoClient(cfg) - ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) - defer cancel() - - raw, cerr := ic.AllMids(ctx, cfg.Dex) - if cerr != nil { - result["connectivity"] = map[string]string{ - "status": "failed", - "error": cerr.Error(), - } - } else { - // Count coins to give useful feedback. - var mids map[string]string - if jerr := json.Unmarshal(raw, &mids); jerr == nil { - result["connectivity"] = map[string]any{ - "status": "ok", - "coins": len(mids), - } - } else { - result["connectivity"] = map[string]string{ - "status": "ok", - } - } - } - - out, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - if _, err = fmt.Fprintln(cmd.OutOrStdout(), string(out)); err != nil { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { return err } @@ -265,6 +212,17 @@ func newConfigTestCmd() *cobra.Command { // for use by config show and config test (which skip PersistentPreRunE). // String flags are filtered for non-empty to avoid overriding file/default // values; "config" always passes since it has a non-empty default. +func configConnectivityStatus(ctx context.Context, cfg *config.Config) any { + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, mids, err := fetchMids(probeCtx, cfg, cfg.Dex) + if err != nil { + return map[string]string{"status": "failed", "error": err.Error()} + } + return map[string]any{"status": "ok", "coins": len(mids)} +} + func newShowViper(cmd *cobra.Command) *viper.Viper { v := viper.New() diff --git a/cmd/helpers.go b/cmd/helpers.go index 9f2bf7f..28ca5b4 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -1,17 +1,25 @@ package cmd import ( + "context" "encoding/json" "os" + "path/filepath" "strconv" + "strings" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/client" "github.com/timbrinded/hlgo/pkg/config" + "github.com/timbrinded/hlgo/pkg/exchange" "github.com/timbrinded/hlgo/pkg/info" "github.com/timbrinded/hlgo/pkg/output" + "github.com/timbrinded/hlgo/pkg/resolver" + "github.com/timbrinded/hlgo/pkg/signer" ) const ( @@ -31,9 +39,52 @@ func baseURL(cfg *config.Config) string { return mainnetURL } +// buildHTTPClient creates a raw HTTP client for the current config. +func buildHTTPClient(cfg *config.Config) *client.Client { + return client.NewClient(baseURL(cfg)) +} + // buildInfoClient creates an InfoClient configured from the current config. func buildInfoClient(cfg *config.Config) *info.InfoClient { - return info.NewInfoClient(client.NewClient(baseURL(cfg))) + return info.NewInfoClient(buildHTTPClient(cfg)) +} + +func buildResolver(cfg *config.Config) resolver.Resolver { + return resolver.NewResolver( + buildHTTPClient(cfg), + resolveCacheDir(cfg), + time.Duration(cfg.MetadataTTL)*time.Second, + ) +} + +// buildExecutor constructs an exchange.Executor from the current config. +func buildExecutor(cfg *config.Config) (*exchange.Executor, error) { + keyHex := os.Getenv(cfg.PrivateKeyEnv) + if keyHex == "" { + return nil, output.NewCLIError(output.ErrConfig, "private key not set"). + WithDetails("env_var", cfg.PrivateKeyEnv) + } + + s, err := signer.NewSigner(keyHex) + if err != nil { + return nil, err + } + + httpClient := buildHTTPClient(cfg) + assetResolver := resolver.NewResolver(httpClient, resolveCacheDir(cfg), time.Duration(cfg.MetadataTTL)*time.Second) + return exchange.NewExecutor(s, httpClient, assetResolver, !cfg.Testnet), nil +} + +// resolveCacheDir returns the cache directory path for the current network. +func resolveCacheDir(cfg *config.Config) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + if cfg.Testnet { + return filepath.Join(home, ".hlgo", "cache", "testnet") + } + return filepath.Join(home, ".hlgo", "cache", "mainnet") } // parseTimeFlag parses a time string as either Unix milliseconds or ISO 8601. @@ -63,6 +114,154 @@ func parseTimeFlag(s string) (int64, error) { WithDetails("hint", "use Unix milliseconds or ISO 8601 (e.g. 2024-01-15T10:30:00Z)") } +func parseDecimalField(field, value string) (decimal.Decimal, error) { + parsed, err := decimal.NewFromString(value) + if err != nil { + return decimal.Decimal{}, output.NewCLIError(output.ErrValidation, "invalid "+field). + WithDetails("value", value) + } + return parsed, nil +} + +func parseOrderSide(side string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(side)) + if normalized == "buy" || normalized == "sell" { + return normalized, nil + } + return "", output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). + WithDetails("value", side) +} + +func resolveOrderTIF(tif string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(tif)) { + case "gtc": + return "Gtc", true + case "ioc": + return "Ioc", true + case "alo": + return "Alo", true + default: + return "", false + } +} + +func parseOrderTIF(tif string) (string, error) { + wireTIF, ok := resolveOrderTIF(tif) + if ok { + return wireTIF, nil + } + return "", output.NewCLIError(output.ErrValidation, "invalid tif: "+tif). + WithDetails("value", tif). + WithDetails("valid", "gtc, ioc, alo") +} + +func parseOptionalExpiresAfter(cmd *cobra.Command) (*int64, error) { + expiresAfterText, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag + if expiresAfterText == "" { + return nil, nil + } + + ms, err := parseTimeFlag(expiresAfterText) + if err != nil { + return nil, err + } else if ms <= 0 { + return nil, output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") + } + return &ms, nil +} + +func parseOptionalBuilder(cmd *cobra.Command) (*exchange.BuilderInfo, error) { + hasBuilder, hasBuilderFee := cmd.Flags().Changed("builder"), cmd.Flags().Changed("builder-fee-tenths-bp") + if hasBuilder != hasBuilderFee { + return nil, output.NewCLIError(output.ErrValidation, "--builder and --builder-fee-tenths-bp must be provided together") + } + if !hasBuilder { + return nil, nil + } + + builderAddress, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag + builderFeeTenthsBP, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag + builderAddress = strings.ToLower(strings.TrimSpace(builderAddress)) + if !common.IsHexAddress(builderAddress) { + return nil, output.NewCLIError(output.ErrValidation, "invalid builder address"). + WithDetails("builder", builderAddress) + } else if builderFeeTenthsBP < 0 { + return nil, output.NewCLIError(output.ErrValidation, "builder fee must be non-negative"). + WithDetails("builder_fee_tenths_bp", builderFeeTenthsBP) + } + return &exchange.BuilderInfo{B: builderAddress, F: builderFeeTenthsBP}, nil +} + +func stringPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func confirmationAccepted(cmd *cobra.Command) bool { + confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag + yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag + return confirm || yes +} + +func mustMarkRequiredFlags(cmd *cobra.Command, names ...string) { + for _, name := range names { + if err := cmd.MarkFlagRequired(name); err != nil { + panic("mustMarkRequiredFlags: " + err.Error()) + } + } +} + +func newHelpCommandGroup(use, short, long string, subcommands ...*cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + Short: short, + Long: long, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(subcommands...) + return cmd +} + +func resolveAddressFlagUser(cmd *cobra.Command, cfg *config.Config) (string, error) { + address, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag + return info.ResolveUserAddress(address, cfg) +} + +func fetchOpenOrders(cmd *cobra.Command, cfg *config.Config, user, dex string) (json.RawMessage, info.OpenOrdersResult, error) { + raw, err := buildInfoClient(cfg).FrontendOpenOrders(cmd.Context(), user, dex) + if err != nil { + return nil, nil, err + } + orders, err := info.ParseOpenOrdersResult(raw) + return raw, orders, err +} + +func fetchMids(ctx context.Context, cfg *config.Config, dex string) (json.RawMessage, info.MidsResult, error) { + raw, err := buildInfoClient(cfg).AllMids(ctx, dex) + if err != nil { + return nil, nil, err + } + mids, err := info.ParseMidsResult(raw) + return raw, mids, err +} + +func fetchPerpState(ctx context.Context, cfg *config.Config, user, dex string) (json.RawMessage, *info.StateResult, error) { + raw, err := buildInfoClient(cfg).ClearinghouseState(ctx, user, dex) + if err != nil { + return nil, nil, err + } + state, err := info.ParseStateResult(raw) + return raw, state, err +} + +func printOKMessage(cmd *cobra.Command, cfg *config.Config, message string) error { + return printResult(cmd, cfg, mustMarshal(map[string]string{"status": "ok", "message": message}), nil) +} + // printResult outputs the result in the configured format. // For JSON format, raw API response is printed directly (preserving precision). // For table/CSV, the Tabular implementation is used. If tab is nil, falls back to JSON. diff --git a/cmd/info.go b/cmd/info.go index 4f0a637..c91d368 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -3,18 +3,12 @@ package cmd import "github.com/spf13/cobra" func newInfoCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "info", - Short: "Read market and account data from Hyperliquid", - Long: `Query the Hyperliquid Info API for market data, order books, trades, + return newHelpCommandGroup( + "info", + "Read market and account data from Hyperliquid", + `Query the Hyperliquid Info API for market data, order books, trades, candles, funding rates, account state, and open orders. All info commands are read-only and require no wallet configuration.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand( newInfoLookupCmd(), newInfoMidsCmd(), newInfoMetaCmd(), @@ -31,6 +25,4 @@ are read-only and require no wallet configuration.`, newInfoFundingCmd(), newInfoPerpDexsCmd(), ) - - return cmd } diff --git a/cmd/info_funding.go b/cmd/info_funding.go index 084c8ee..363587c 100644 --- a/cmd/info_funding.go +++ b/cmd/info_funding.go @@ -17,25 +17,8 @@ func newInfoFundingCmd() *cobra.Command { coin := args[0] predicted, _ := cmd.Flags().GetBool("predicted") //nolint:errcheck // known flag - ic := buildInfoClient(cfg) - if predicted { - if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.PredictedFundingsRequest{ - Type: "predictedFundings", - }), nil) - } - - raw, err := ic.PredictedFundings(cmd.Context()) - if err != nil { - return err - } - - result, err := info.ParsePredictedFundingsResult(raw) - if err != nil { - return err - } - return printResult(cmd, cfg, raw, result) + return runPredictedFunding(cmd, cfg) } startStr, _ := cmd.Flags().GetString("start") //nolint:errcheck // known flag @@ -51,12 +34,10 @@ func newInfoFundingCmd() *cobra.Command { } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.FundingHistoryRequest{ - Type: "fundingHistory", Coin: coin, StartTime: startTime, EndTime: endTime, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.FundingHistoryRequest{Type: "fundingHistory", Coin: coin, StartTime: startTime, EndTime: endTime}), nil) } - raw, err := ic.FundingHistory(cmd.Context(), coin, startTime, endTime) + raw, err := buildInfoClient(cfg).FundingHistory(cmd.Context(), coin, startTime, endTime) if err != nil { return err } @@ -74,19 +55,35 @@ func newInfoFundingCmd() *cobra.Command { return cmd } +func runPredictedFunding(cmd *cobra.Command, cfg *config.Config) error { + if cfg.DryRun { + return printResult(cmd, cfg, mustMarshal(info.PredictedFundingsRequest{Type: "predictedFundings"}), nil) + } + + raw, err := buildInfoClient(cfg).PredictedFundings(cmd.Context()) + if err != nil { + return err + } + + result, err := info.ParsePredictedFundingsResult(raw) + if err != nil { + return err + } + return printResult(cmd, cfg, raw, result) +} + func newInfoPerpDexsCmd() *cobra.Command { return &cobra.Command{ Use: "perp-dexs", Short: "List HIP-3 perp dexes", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - ic := buildInfoClient(cfg) if cfg.DryRun { return printResult(cmd, cfg, mustMarshal(info.PerpDexsRequest{Type: "perpDexs"}), nil) } - raw, err := ic.PerpDexs(cmd.Context()) + raw, err := buildInfoClient(cfg).PerpDexs(cmd.Context()) if err != nil { return err } diff --git a/cmd/info_lookup.go b/cmd/info_lookup.go index 21f5977..97e8aae 100644 --- a/cmd/info_lookup.go +++ b/cmd/info_lookup.go @@ -1,9 +1,6 @@ package cmd import ( - "context" - "encoding/json" - "sort" "strconv" "strings" @@ -55,51 +52,20 @@ func (t infoLookupTable) Rows() [][]string { return nil } rows := make([][]string, 0, len(t.result.Matches)) - for _, m := range t.result.Matches { + for _, match := range t.result.Matches { rows = append(rows, []string{ - strconv.Itoa(m.AssetID), - m.Coin, - m.MarketType, - m.Dex, - strconv.Itoa(m.SzDecimals), - strings.Join(m.Aliases, ","), - m.MatchType, + strconv.Itoa(match.AssetID), + match.Coin, + match.MarketType, + match.Dex, + strconv.Itoa(match.SzDecimals), + strings.Join(match.Aliases, ","), + match.MatchType, }) } return rows } -type lookupPerpMeta struct { - Universe []lookupPerpAsset `json:"universe"` -} - -type lookupPerpAsset struct { - Name string `json:"name"` - SzDecimals int `json:"szDecimals"` -} - -type lookupSpotMeta struct { - Universe []lookupSpotMarket `json:"universe"` - Tokens []lookupSpotToken `json:"tokens"` -} - -type lookupSpotMarket struct { - Name string `json:"name"` - Index int `json:"index"` - Tokens []int `json:"tokens"` -} - -type lookupSpotToken struct { - Name string `json:"name"` - Index int `json:"index"` - SzDecimals int `json:"szDecimals"` - FullName string `json:"fullName"` -} - -type lookupDexName struct { - Name string `json:"name"` -} - type lookupAssetRecord struct { Coin string AssetID int @@ -109,11 +75,6 @@ type lookupAssetRecord struct { Aliases []string } -type lookupScoredMatch struct { - match infoLookupMatch - rank int -} - func newInfoLookupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "lookup ", @@ -129,7 +90,6 @@ and optional HIP-3 dex scopes. Returns canonical --coin values and asset IDs.`, dexExplicit := cmd.Flags().Changed("dex") dex := strings.ToLower(strings.TrimSpace(cfg.Dex)) - if dexExplicit && allDexes { return output.NewCLIError(output.ErrValidation, "--dex and --all-dexes are mutually exclusive") } @@ -138,12 +98,7 @@ and optional HIP-3 dex scopes. Returns canonical --coin values and asset IDs.`, WithDetails("value", limit) } - scope := infoLookupScope{ - CorePerp: true, - Spot: true, - Dex: dex, - AllDexes: allDexes, - } + scope := infoLookupScope{CorePerp: true, Spot: true, Dex: dex, AllDexes: allDexes} mode := "name" if _, err := strconv.Atoi(query); err == nil { @@ -151,58 +106,13 @@ and optional HIP-3 dex scopes. Returns canonical --coin values and asset IDs.`, } if cfg.DryRun { - requests := map[string]any{ - "core_perp_meta": info.MetaRequest{Type: "meta"}, - "spot_meta": info.MetaRequest{Type: "spotMeta"}, - } - if dex != "" || allDexes { - requests["perp_dexs"] = info.PerpDexsRequest{Type: "perpDexs"} - } - if dex != "" { - requests["hip3_meta"] = []info.MetaRequest{{Type: "meta", Dex: dex}} - } - if allDexes { - requests["hip3_meta"] = []map[string]string{{"type": "meta", "dex": ""}} - } - - return printResult(cmd, cfg, mustMarshal(map[string]any{ - "query": query, - "mode": mode, - "scope": scope, - "limit": limit, - "requests": requests, - }), nil) + return printResult(cmd, cfg, mustMarshal(infoLookupDryRunPayload(query, mode, scope, limit)), nil) } - ic := buildInfoClient(cfg) - records := make([]lookupAssetRecord, 0, 512) - - corePerps, err := fetchCorePerpLookupRecords(cmd.Context(), ic) + records, err := fetchInfoLookupRecords(cmd, cfg, dex, allDexes) if err != nil { return err } - records = append(records, corePerps...) - - spotRecords, err := fetchSpotLookupRecords(cmd.Context(), ic) - if err != nil { - return err - } - records = append(records, spotRecords...) - - if allDexes { - hip3Records, err := fetchAllHip3LookupRecords(cmd.Context(), ic) - if err != nil { - return err - } - records = append(records, hip3Records...) - } else if dex != "" { - hip3Records, err := fetchSingleHip3LookupRecords(cmd.Context(), ic, dex) - if err != nil { - return err - } - records = append(records, hip3Records...) - } - matches := scoreLookupMatches(records, query, mode, limit) result := &infoLookupResult{ Query: query, @@ -222,439 +132,46 @@ and optional HIP-3 dex scopes. Returns canonical --coin values and asset IDs.`, return cmd } -func fetchCorePerpLookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { - raw, err := ic.Meta(ctx, "") - if err != nil { - return nil, err +func infoLookupDryRunPayload(query, mode string, scope infoLookupScope, limit int) map[string]any { + requests := map[string]any{"core_perp_meta": info.MetaRequest{Type: "meta"}, "spot_meta": info.MetaRequest{Type: "spotMeta"}} + if scope.Dex != "" || scope.AllDexes { + requests["perp_dexs"] = info.PerpDexsRequest{Type: "perpDexs"} } - - var meta lookupPerpMeta - if err := json.Unmarshal(raw, &meta); err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to parse core perp metadata"). - WithDetails("cause", err.Error()) + if scope.Dex != "" { + requests["hip3_meta"] = []info.MetaRequest{{Type: "meta", Dex: scope.Dex}} } - - out := make([]lookupAssetRecord, 0, len(meta.Universe)) - for i, asset := range meta.Universe { - out = append(out, lookupAssetRecord{ - Coin: asset.Name, - AssetID: i, - MarketType: "perp", - SzDecimals: asset.SzDecimals, - Aliases: buildLookupAliases(asset.Name), - }) + if scope.AllDexes { + requests["hip3_meta"] = []map[string]string{{"type": "meta", "dex": ""}} } - return out, nil + return map[string]any{"query": query, "mode": mode, "scope": scope, "limit": limit, "requests": requests} } -func fetchSpotLookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { - raw, err := ic.SpotMeta(ctx) +func fetchInfoLookupRecords(cmd *cobra.Command, cfg *config.Config, dex string, allDexes bool) ([]lookupAssetRecord, error) { + ic := buildInfoClient(cfg) + records := make([]lookupAssetRecord, 0, 512) + corePerps, err := fetchCorePerpLookupRecords(cmd.Context(), ic) if err != nil { return nil, err } + records = append(records, corePerps...) - var meta lookupSpotMeta - if err := json.Unmarshal(raw, &meta); err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to parse spot metadata"). - WithDetails("cause", err.Error()) - } - - tokenByIndex := make(map[int]lookupSpotToken, len(meta.Tokens)) - for _, tok := range meta.Tokens { - tokenByIndex[tok.Index] = tok - } - - out := make([]lookupAssetRecord, 0, len(meta.Universe)) - for _, market := range meta.Universe { - szDecimals := 0 - aliases := buildLookupAliases(market.Name) - - if len(market.Tokens) > 0 { - baseIdx := market.Tokens[0] - if tok, ok := tokenByIndex[baseIdx]; ok { - aliases = append(aliases, tok.Name) - aliases = append(aliases, unitTokenAliases(tok)...) - szDecimals = tok.SzDecimals - } - } - - out = append(out, lookupAssetRecord{ - Coin: market.Name, - AssetID: 10000 + market.Index, - MarketType: "spot", - SzDecimals: szDecimals, - Aliases: dedupeLookupAliases(aliases, market.Name), - }) - } - return out, nil -} - -func fetchSingleHip3LookupRecords(ctx context.Context, ic *info.InfoClient, dex string) ([]lookupAssetRecord, error) { - offsets, err := fetchHip3DexOffsets(ctx, ic) - if err != nil { - return nil, err - } - offset, ok := offsets[dex] - if !ok { - return nil, output.NewCLIError(output.ErrValidation, "unknown HIP-3 dex: "+dex). - WithDetails("dex", dex) - } - - return fetchHip3MetaRecords(ctx, ic, dex, offset) -} - -func fetchAllHip3LookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { - offsets, err := fetchHip3DexOffsets(ctx, ic) + spotRecords, err := fetchSpotLookupRecords(cmd.Context(), ic) if err != nil { return nil, err } - dexes := make([]string, 0, len(offsets)) - for dex := range offsets { - dexes = append(dexes, dex) - } - sort.Strings(dexes) - - out := make([]lookupAssetRecord, 0, len(dexes)*16) - for _, dex := range dexes { - records, err := fetchHip3MetaRecords(ctx, ic, dex, offsets[dex]) + records = append(records, spotRecords...) + if allDexes { + hip3Records, err := fetchAllHip3LookupRecords(cmd.Context(), ic) if err != nil { return nil, err } - out = append(out, records...) - } - return out, nil -} - -func fetchHip3MetaRecords(ctx context.Context, ic *info.InfoClient, dex string, offset int) ([]lookupAssetRecord, error) { - raw, err := ic.Meta(ctx, dex) - if err != nil { - return nil, err - } - - var meta lookupPerpMeta - if err := json.Unmarshal(raw, &meta); err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to parse HIP-3 metadata"). - WithDetails("dex", dex). - WithDetails("cause", err.Error()) - } - - out := make([]lookupAssetRecord, 0, len(meta.Universe)) - for i, asset := range meta.Universe { - out = append(out, lookupAssetRecord{ - Coin: asset.Name, - AssetID: offset + i, - MarketType: "perp", - Dex: dex, - SzDecimals: asset.SzDecimals, - Aliases: buildLookupAliases(asset.Name), - }) - } - return out, nil -} - -func fetchHip3DexOffsets(ctx context.Context, ic *info.InfoClient) (map[string]int, error) { - raw, err := ic.PerpDexs(ctx) - if err != nil { - return nil, err - } - - var entries []json.RawMessage - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to parse perp dex list"). - WithDetails("cause", err.Error()) - } - - offsets := make(map[string]int) - for i, entry := range entries { - if i == 0 || strings.TrimSpace(string(entry)) == "null" { - continue - } - - var dex lookupDexName - if err := json.Unmarshal(entry, &dex); err != nil { - continue - } - name := strings.ToLower(strings.TrimSpace(dex.Name)) - if name == "" { - continue - } - offsets[name] = 110000 + (i-1)*10000 - } - - return offsets, nil -} - -func buildLookupAliases(coin string) []string { - trimmed := strings.TrimSpace(coin) - if trimmed == "" { - return []string{} - } - - aliases := make([]string, 0, 8) - candidate := trimmed - if idx := strings.Index(candidate, ":"); idx > 0 && idx+1 < len(candidate) { - suffix := strings.TrimSpace(candidate[idx+1:]) - if suffix != "" { - aliases = append(aliases, suffix) - candidate = suffix - } - } - - base, quote, ok := splitMarketSymbol(candidate) - if ok { - aliases = append(aliases, - base, - base+"/"+quote, - base+"-"+quote, - base+quote, - ) - if strings.HasSuffix(strings.ToUpper(quote), "USD") { - aliases = append(aliases, base+"USD") - } - } - - return dedupeLookupAliases(aliases, coin) -} - -func splitMarketSymbol(s string) (base string, quote string, ok bool) { - if parts := strings.SplitN(s, "/", 2); len(parts) == 2 { - base = strings.TrimSpace(parts[0]) - quote = strings.TrimSpace(parts[1]) - if base != "" && quote != "" { - return base, quote, true - } - } - - if parts := strings.SplitN(s, "-", 2); len(parts) == 2 { - base = strings.TrimSpace(parts[0]) - quote = strings.TrimSpace(parts[1]) - if base != "" && quote != "" { - return base, quote, true - } - } - - return "", "", false -} - -func unitTokenAliases(token lookupSpotToken) []string { - fullName := strings.TrimSpace(token.FullName) - if !strings.HasPrefix(strings.ToUpper(fullName), "UNIT ") { - return nil - } - - name := strings.ToUpper(token.Name) - if len(name) < 2 || !strings.HasPrefix(name, "U") { - return nil - } - - aliases := make([]string, 0, len(name)-1) - seen := make(map[string]struct{}, len(name)-1) - for stripped := name; len(stripped) > 1 && strings.HasPrefix(stripped, "U"); { - stripped = stripped[1:] - if _, ok := seen[stripped]; ok { - continue - } - seen[stripped] = struct{}{} - aliases = append(aliases, stripped) - } - - return aliases -} - -func dedupeLookupAliases(aliases []string, coin string) []string { - seen := make(map[string]struct{}, len(aliases)) - out := make([]string, 0, len(aliases)) - coinLower := strings.ToLower(coin) - for _, alias := range aliases { - trimmed := strings.TrimSpace(alias) - if trimmed == "" { - continue - } - key := strings.ToLower(trimmed) - if key == coinLower { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, trimmed) - } - sort.Strings(out) - return out -} - -func scoreLookupMatches(records []lookupAssetRecord, query, mode string, limit int) []infoLookupMatch { - if len(records) == 0 { - return []infoLookupMatch{} - } - - var scored []lookupScoredMatch - if mode == "id" { - id, err := strconv.Atoi(query) + records = append(records, hip3Records...) + } else if dex != "" { + hip3Records, err := fetchSingleHip3LookupRecords(cmd.Context(), ic, dex) if err != nil { - return []infoLookupMatch{} - } - scored = scoreIDMatches(records, id) - } else { - scored = scoreNameMatches(records, query) - } - - sort.Slice(scored, func(i, j int) bool { - if scored[i].rank != scored[j].rank { - return scored[i].rank < scored[j].rank - } - leftType := marketTypeSortRank(scored[i].match.MarketType) - rightType := marketTypeSortRank(scored[j].match.MarketType) - if leftType != rightType { - return leftType < rightType - } - if scored[i].match.Dex != scored[j].match.Dex { - return scored[i].match.Dex < scored[j].match.Dex - } - if scored[i].match.Coin != scored[j].match.Coin { - return scored[i].match.Coin < scored[j].match.Coin - } - return scored[i].match.AssetID < scored[j].match.AssetID - }) - - if len(scored) > limit { - scored = scored[:limit] - } - - out := make([]infoLookupMatch, 0, len(scored)) - for _, s := range scored { - out = append(out, s.match) - } - return out -} - -func marketTypeSortRank(marketType string) int { - switch marketType { - case "perp": - return 0 - case "spot": - return 1 - default: - return 2 - } -} - -func scoreIDMatches(records []lookupAssetRecord, id int) []lookupScoredMatch { - out := make([]lookupScoredMatch, 0, 2) - for _, r := range records { - if r.AssetID != id { - continue - } - out = append(out, lookupScoredMatch{ - match: infoLookupMatch{ - Coin: r.Coin, - AssetID: r.AssetID, - MarketType: r.MarketType, - Dex: r.Dex, - SzDecimals: r.SzDecimals, - Aliases: r.Aliases, - MatchType: "id", - }, - rank: 0, - }) - } - return out -} - -func scoreNameMatches(records []lookupAssetRecord, query string) []lookupScoredMatch { - q := strings.ToLower(strings.TrimSpace(query)) - qNorm := normalizeLookupToken(q) - out := make([]lookupScoredMatch, 0, 16) - - for _, r := range records { - rank := -1 - matchType := "" - for _, token := range append([]string{r.Coin}, r.Aliases...) { - candidate := strings.ToLower(strings.TrimSpace(token)) - if candidate == "" { - continue - } - candidateNorm := normalizeLookupToken(candidate) - - score, label := scoreLookupToken(candidate, candidateNorm, q, qNorm) - if score == -1 { - continue - } - if rank == -1 || score < rank { - rank = score - matchType = label - } - if rank == 0 { - break - } - } - - if rank == -1 { - continue - } - - out = append(out, lookupScoredMatch{ - match: infoLookupMatch{ - Coin: r.Coin, - AssetID: r.AssetID, - MarketType: r.MarketType, - Dex: r.Dex, - SzDecimals: r.SzDecimals, - Aliases: r.Aliases, - MatchType: matchType, - }, - rank: rank, - }) - } - - return out -} - -func scoreLookupToken(candidate, candidateNorm, query, queryNorm string) (int, string) { - switch { - case candidate == query: - return 0, "exact" - case queryNorm != "" && candidateNorm == queryNorm: - return 1, "normalized_exact" - case strings.HasPrefix(candidate, query): - return 2, "prefix" - case queryNorm != "" && strings.HasPrefix(candidateNorm, queryNorm): - return 3, "normalized_prefix" - case strings.Contains(candidate, query): - return 4, "contains" - case queryNorm != "" && strings.Contains(candidateNorm, queryNorm): - return 5, "normalized_contains" - case len(queryNorm) >= 3 && isSubsequence(queryNorm, candidateNorm): - return 6, "subsequence" - default: - return -1, "" - } -} - -func normalizeLookupToken(s string) string { - if s == "" { - return "" - } - var b strings.Builder - b.Grow(len(s)) - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - } - } - return strings.ToLower(b.String()) -} - -func isSubsequence(needle, haystack string) bool { - if needle == "" { - return true - } - j := 0 - for i := 0; i < len(haystack) && j < len(needle); i++ { - if haystack[i] == needle[j] { - j++ + return nil, err } + records = append(records, hip3Records...) } - return j == len(needle) + return records, nil } diff --git a/cmd/info_lookup_aliases.go b/cmd/info_lookup_aliases.go new file mode 100644 index 0000000..9342c6f --- /dev/null +++ b/cmd/info_lookup_aliases.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "sort" + "strings" +) + +func buildLookupAliases(coin string) []string { + trimmed := strings.TrimSpace(coin) + if trimmed == "" { + return []string{} + } + + aliases := make([]string, 0, 8) + candidate := trimmed + if idx := strings.Index(candidate, ":"); idx > 0 && idx+1 < len(candidate) { + suffix := strings.TrimSpace(candidate[idx+1:]) + if suffix != "" { + aliases = append(aliases, suffix) + candidate = suffix + } + } + + base, quote, ok := splitMarketSymbol(candidate) + if ok { + aliases = append(aliases, + base, + base+"/"+quote, + base+"-"+quote, + base+quote, + ) + if strings.HasSuffix(strings.ToUpper(quote), "USD") { + aliases = append(aliases, base+"USD") + } + } + + return dedupeLookupAliases(aliases, coin) +} + +func splitMarketSymbol(s string) (base string, quote string, ok bool) { + if parts := strings.SplitN(s, "/", 2); len(parts) == 2 { + base = strings.TrimSpace(parts[0]) + quote = strings.TrimSpace(parts[1]) + if base != "" && quote != "" { + return base, quote, true + } + } + + if parts := strings.SplitN(s, "-", 2); len(parts) == 2 { + base = strings.TrimSpace(parts[0]) + quote = strings.TrimSpace(parts[1]) + if base != "" && quote != "" { + return base, quote, true + } + } + + return "", "", false +} + +func unitTokenAliases(token lookupSpotToken) []string { + fullName := strings.TrimSpace(token.FullName) + if !strings.HasPrefix(strings.ToUpper(fullName), "UNIT ") { + return nil + } + + name := strings.ToUpper(token.Name) + if len(name) < 2 || !strings.HasPrefix(name, "U") { + return nil + } + + aliases := make([]string, 0, len(name)-1) + seen := make(map[string]struct{}, len(name)-1) + for stripped := name; len(stripped) > 1 && strings.HasPrefix(stripped, "U"); { + stripped = stripped[1:] + if _, ok := seen[stripped]; ok { + continue + } + seen[stripped] = struct{}{} + aliases = append(aliases, stripped) + } + + return aliases +} + +func dedupeLookupAliases(aliases []string, coin string) []string { + seen := make(map[string]struct{}, len(aliases)) + out := make([]string, 0, len(aliases)) + coinLower := strings.ToLower(coin) + for _, alias := range aliases { + trimmed := strings.TrimSpace(alias) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if key == coinLower { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, trimmed) + } + sort.Strings(out) + return out +} diff --git a/cmd/info_lookup_fetch.go b/cmd/info_lookup_fetch.go new file mode 100644 index 0000000..8d3ae6f --- /dev/null +++ b/cmd/info_lookup_fetch.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "context" + "encoding/json" + "sort" + "strings" + + "github.com/timbrinded/hlgo/pkg/info" + "github.com/timbrinded/hlgo/pkg/output" +) + +type lookupPerpMeta struct { + Universe []lookupPerpAsset `json:"universe"` +} + +type lookupPerpAsset struct { + Name string `json:"name"` + SzDecimals int `json:"szDecimals"` +} + +type lookupSpotMeta struct { + Universe []lookupSpotMarket `json:"universe"` + Tokens []lookupSpotToken `json:"tokens"` +} + +type lookupSpotMarket struct { + Name string `json:"name"` + Index int `json:"index"` + Tokens []int `json:"tokens"` +} + +type lookupSpotToken struct { + Name string `json:"name"` + Index int `json:"index"` + SzDecimals int `json:"szDecimals"` + FullName string `json:"fullName"` +} + +type lookupDexName struct { + Name string `json:"name"` +} + +func fetchCorePerpLookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { + raw, err := ic.Meta(ctx, "") + if err != nil { + return nil, err + } + + var meta lookupPerpMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to parse core perp metadata"). + WithDetails("cause", err.Error()) + } + + out := make([]lookupAssetRecord, 0, len(meta.Universe)) + for i, asset := range meta.Universe { + out = append(out, lookupAssetRecord{ + Coin: asset.Name, + AssetID: i, + MarketType: "perp", + SzDecimals: asset.SzDecimals, + Aliases: buildLookupAliases(asset.Name), + }) + } + return out, nil +} + +func fetchSpotLookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { + raw, err := ic.SpotMeta(ctx) + if err != nil { + return nil, err + } + + var meta lookupSpotMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to parse spot metadata"). + WithDetails("cause", err.Error()) + } + + tokenByIndex := make(map[int]lookupSpotToken, len(meta.Tokens)) + for _, tok := range meta.Tokens { + tokenByIndex[tok.Index] = tok + } + + out := make([]lookupAssetRecord, 0, len(meta.Universe)) + for _, market := range meta.Universe { + szDecimals := 0 + aliases := buildLookupAliases(market.Name) + + if len(market.Tokens) > 0 { + baseIdx := market.Tokens[0] + if tok, ok := tokenByIndex[baseIdx]; ok { + aliases = append(aliases, tok.Name) + aliases = append(aliases, unitTokenAliases(tok)...) + szDecimals = tok.SzDecimals + } + } + + out = append(out, lookupAssetRecord{ + Coin: market.Name, + AssetID: 10000 + market.Index, + MarketType: "spot", + SzDecimals: szDecimals, + Aliases: dedupeLookupAliases(aliases, market.Name), + }) + } + return out, nil +} + +func fetchSingleHip3LookupRecords(ctx context.Context, ic *info.InfoClient, dex string) ([]lookupAssetRecord, error) { + offsets, err := fetchHip3DexOffsets(ctx, ic) + if err != nil { + return nil, err + } + offset, ok := offsets[dex] + if !ok { + return nil, output.NewCLIError(output.ErrValidation, "unknown HIP-3 dex: "+dex). + WithDetails("dex", dex) + } + + return fetchHip3MetaRecords(ctx, ic, dex, offset) +} + +func fetchAllHip3LookupRecords(ctx context.Context, ic *info.InfoClient) ([]lookupAssetRecord, error) { + offsets, err := fetchHip3DexOffsets(ctx, ic) + if err != nil { + return nil, err + } + dexes := make([]string, 0, len(offsets)) + for dex := range offsets { + dexes = append(dexes, dex) + } + sort.Strings(dexes) + + out := make([]lookupAssetRecord, 0, len(dexes)*16) + for _, dex := range dexes { + records, err := fetchHip3MetaRecords(ctx, ic, dex, offsets[dex]) + if err != nil { + return nil, err + } + out = append(out, records...) + } + return out, nil +} + +func fetchHip3MetaRecords(ctx context.Context, ic *info.InfoClient, dex string, offset int) ([]lookupAssetRecord, error) { + raw, err := ic.Meta(ctx, dex) + if err != nil { + return nil, err + } + + var meta lookupPerpMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to parse HIP-3 metadata"). + WithDetails("dex", dex). + WithDetails("cause", err.Error()) + } + + out := make([]lookupAssetRecord, 0, len(meta.Universe)) + for i, asset := range meta.Universe { + out = append(out, lookupAssetRecord{ + Coin: asset.Name, + AssetID: offset + i, + MarketType: "perp", + Dex: dex, + SzDecimals: asset.SzDecimals, + Aliases: buildLookupAliases(asset.Name), + }) + } + return out, nil +} + +func fetchHip3DexOffsets(ctx context.Context, ic *info.InfoClient) (map[string]int, error) { + raw, err := ic.PerpDexs(ctx) + if err != nil { + return nil, err + } + + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to parse perp dex list"). + WithDetails("cause", err.Error()) + } + + offsets := make(map[string]int) + for i, entry := range entries { + if i == 0 || strings.TrimSpace(string(entry)) == "null" { + continue + } + + var dex lookupDexName + if err := json.Unmarshal(entry, &dex); err != nil { + continue + } + name := strings.ToLower(strings.TrimSpace(dex.Name)) + if name == "" { + continue + } + offsets[name] = 110000 + (i-1)*10000 + } + + return offsets, nil +} diff --git a/cmd/info_lookup_score.go b/cmd/info_lookup_score.go new file mode 100644 index 0000000..2f2220c --- /dev/null +++ b/cmd/info_lookup_score.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "sort" + "strconv" + "strings" +) + +type lookupScoredMatch struct { + match infoLookupMatch + rank int +} + +func scoreLookupMatches(records []lookupAssetRecord, query, mode string, limit int) []infoLookupMatch { + if len(records) == 0 { + return []infoLookupMatch{} + } + + var scored []lookupScoredMatch + if mode == "id" { + id, err := strconv.Atoi(query) + if err != nil { + return []infoLookupMatch{} + } + scored = scoreIDMatches(records, id) + } else { + scored = scoreNameMatches(records, query) + } + + sort.Slice(scored, func(i, j int) bool { + if scored[i].rank != scored[j].rank { + return scored[i].rank < scored[j].rank + } + leftType := marketTypeSortRank(scored[i].match.MarketType) + rightType := marketTypeSortRank(scored[j].match.MarketType) + if leftType != rightType { + return leftType < rightType + } + if scored[i].match.Dex != scored[j].match.Dex { + return scored[i].match.Dex < scored[j].match.Dex + } + if scored[i].match.Coin != scored[j].match.Coin { + return scored[i].match.Coin < scored[j].match.Coin + } + return scored[i].match.AssetID < scored[j].match.AssetID + }) + + if len(scored) > limit { + scored = scored[:limit] + } + + out := make([]infoLookupMatch, 0, len(scored)) + for _, scoredMatch := range scored { + out = append(out, scoredMatch.match) + } + return out +} + +func marketTypeSortRank(marketType string) int { + switch marketType { + case "perp": + return 0 + case "spot": + return 1 + default: + return 2 + } +} + +func scoreIDMatches(records []lookupAssetRecord, id int) []lookupScoredMatch { + out := make([]lookupScoredMatch, 0, 2) + for _, record := range records { + if record.AssetID != id { + continue + } + out = append(out, lookupScoredMatch{ + match: infoLookupMatch{ + Coin: record.Coin, + AssetID: record.AssetID, + MarketType: record.MarketType, + Dex: record.Dex, + SzDecimals: record.SzDecimals, + Aliases: record.Aliases, + MatchType: "id", + }, + rank: 0, + }) + } + return out +} + +func scoreNameMatches(records []lookupAssetRecord, query string) []lookupScoredMatch { + queryLower := strings.ToLower(strings.TrimSpace(query)) + queryNormalized := normalizeLookupToken(queryLower) + out := make([]lookupScoredMatch, 0, 16) + + for _, record := range records { + rank := -1 + matchType := "" + for _, token := range append([]string{record.Coin}, record.Aliases...) { + candidate := strings.ToLower(strings.TrimSpace(token)) + if candidate == "" { + continue + } + candidateNormalized := normalizeLookupToken(candidate) + + score, label := scoreLookupToken(candidate, candidateNormalized, queryLower, queryNormalized) + if score == -1 { + continue + } + if rank == -1 || score < rank { + rank = score + matchType = label + } + if rank == 0 { + break + } + } + + if rank == -1 { + continue + } + + out = append(out, lookupScoredMatch{ + match: infoLookupMatch{ + Coin: record.Coin, + AssetID: record.AssetID, + MarketType: record.MarketType, + Dex: record.Dex, + SzDecimals: record.SzDecimals, + Aliases: record.Aliases, + MatchType: matchType, + }, + rank: rank, + }) + } + + return out +} + +func scoreLookupToken(candidate, candidateNormalized, query, queryNormalized string) (int, string) { + switch { + case candidate == query: + return 0, "exact" + case queryNormalized != "" && candidateNormalized == queryNormalized: + return 1, "normalized_exact" + case strings.HasPrefix(candidate, query): + return 2, "prefix" + case queryNormalized != "" && strings.HasPrefix(candidateNormalized, queryNormalized): + return 3, "normalized_prefix" + case strings.Contains(candidate, query): + return 4, "contains" + case queryNormalized != "" && strings.Contains(candidateNormalized, queryNormalized): + return 5, "normalized_contains" + case len(queryNormalized) >= 3 && isSubsequence(queryNormalized, candidateNormalized): + return 6, "subsequence" + default: + return -1, "" + } +} + +func normalizeLookupToken(s string) string { + if s == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(s)) + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + } + } + return strings.ToLower(builder.String()) +} + +func isSubsequence(needle, haystack string) bool { + if needle == "" { + return true + } + needleIndex := 0 + for i := 0; i < len(haystack) && needleIndex < len(needle); i++ { + if haystack[i] == needle[needleIndex] { + needleIndex++ + } + } + return needleIndex == len(needle) +} diff --git a/cmd/info_market.go b/cmd/info_market.go index c1ec58b..98f7c2c 100644 --- a/cmd/info_market.go +++ b/cmd/info_market.go @@ -10,11 +10,13 @@ import ( "github.com/timbrinded/hlgo/pkg/output" ) -// validCandleIntervals is the set of intervals accepted by the candle API. -var validCandleIntervals = map[string]bool{ - "1m": true, "3m": true, "5m": true, "15m": true, "30m": true, - "1h": true, "2h": true, "4h": true, "8h": true, "12h": true, - "1d": true, "3d": true, "1w": true, "1M": true, +func isValidCandleInterval(interval string) bool { + switch interval { + case "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w", "1M": + return true + default: + return false + } } func newInfoMidsCmd() *cobra.Command { @@ -23,26 +25,19 @@ func newInfoMidsCmd() *cobra.Command { Short: "Get all mid-market prices", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - ic := buildInfoClient(cfg) dex, _ := cmd.Flags().GetString("dex") //nolint:errcheck // known flag if cfg.DryRun { return printResult(cmd, cfg, mustMarshal(info.AllMidsRequest{Type: "allMids", Dex: dex}), nil) } - raw, err := ic.AllMids(cmd.Context(), dex) - if err != nil { - return err - } - - result, err := info.ParseMidsResult(raw) + raw, result, err := fetchMids(cmd.Context(), cfg, dex) if err != nil { return err } return printResult(cmd, cfg, raw, result) }, } - cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -78,7 +73,6 @@ func newInfoMetaCmd() *cobra.Command { }, } cmd.Flags().Bool("spot", false, "fetch spot metadata instead of perp") - cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -114,7 +108,6 @@ func newInfoMetaAndCtxsCmd() *cobra.Command { }, } cmd.Flags().Bool("spot", false, "fetch spot metadata instead of perp") - cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -125,39 +118,17 @@ func newInfoBookCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromContext(cmd.Context()) - ic := buildInfoClient(cfg) coin := args[0] - sigfigs, _ := cmd.Flags().GetInt("sigfigs") //nolint:errcheck // known flag - depth, _ := cmd.Flags().GetInt("depth") //nolint:errcheck // known flag - mantissa, _ := cmd.Flags().GetInt("mantissa") //nolint:errcheck // known flag - - var nSigFigs *int - if cmd.Flags().Changed("sigfigs") { - nSigFigs = &sigfigs - } - - var mantissaPtr *int - if cmd.Flags().Changed("mantissa") { - if !cmd.Flags().Changed("sigfigs") || sigfigs != 5 { - return output.NewCLIError(output.ErrValidation, "mantissa requires --sigfigs 5") - } - if mantissa != 1 && mantissa != 2 && mantissa != 5 { - return output.NewCLIError(output.ErrValidation, "mantissa must be one of 1, 2, or 5"). - WithDetails("mantissa", mantissa) - } - mantissaPtr = &mantissa + depth, nSigFigs, mantissaPtr, err := parseBookAggregation(cmd) + if err != nil { + return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.L2BookRequest{ - Type: "l2Book", - Coin: coin, - NSigFigs: nSigFigs, - Mantissa: mantissaPtr, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.L2BookRequest{Type: "l2Book", Coin: coin, NSigFigs: nSigFigs, Mantissa: mantissaPtr}), nil) } - raw, err := ic.L2Book(cmd.Context(), coin, nSigFigs, mantissaPtr) + raw, err := buildInfoClient(cfg).L2Book(cmd.Context(), coin, nSigFigs, mantissaPtr) if err != nil { return err } @@ -185,6 +156,28 @@ func newInfoBookCmd() *cobra.Command { return cmd } +func parseBookAggregation(cmd *cobra.Command) (depth int, nSigFigs, mantissa *int, err error) { + sigfigs, _ := cmd.Flags().GetInt("sigfigs") //nolint:errcheck // known flag + depth, _ = cmd.Flags().GetInt("depth") //nolint:errcheck // known flag + mantissaValue, _ := cmd.Flags().GetInt("mantissa") //nolint:errcheck // known flag + + if cmd.Flags().Changed("sigfigs") { + nSigFigs = &sigfigs + } + if !cmd.Flags().Changed("mantissa") { + return depth, nSigFigs, nil, nil + } + if !cmd.Flags().Changed("sigfigs") || sigfigs != 5 { + return 0, nil, nil, output.NewCLIError(output.ErrValidation, "mantissa requires --sigfigs 5") + } + if mantissaValue != 1 && mantissaValue != 2 && mantissaValue != 5 { + return 0, nil, nil, output.NewCLIError(output.ErrValidation, "mantissa must be one of 1, 2, or 5"). + WithDetails("mantissa", mantissaValue) + } + mantissa = &mantissaValue + return depth, nSigFigs, mantissa, nil +} + func newInfoTradesCmd() *cobra.Command { return &cobra.Command{ Use: "trades ", @@ -192,14 +185,13 @@ func newInfoTradesCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromContext(cmd.Context()) - ic := buildInfoClient(cfg) coin := args[0] if cfg.DryRun { return printResult(cmd, cfg, mustMarshal(info.RecentTradesRequest{Type: "recentTrades", Coin: coin}), nil) } - raw, err := ic.RecentTrades(cmd.Context(), coin) + raw, err := buildInfoClient(cfg).RecentTrades(cmd.Context(), coin) if err != nil { return err } @@ -225,47 +217,22 @@ func newInfoCandlesCmd() *cobra.Command { coin := args[0] interval := args[1] - if !validCandleIntervals[interval] { + if !isValidCandleInterval(interval) { return output.NewCLIError(output.ErrValidation, "invalid candle interval: "+interval). WithDetails("interval", interval). WithDetails("valid", "1m,3m,5m,15m,30m,1h,2h,4h,8h,12h,1d,3d,1w,1M") } - ic := buildInfoClient(cfg) - startStr, _ := cmd.Flags().GetString("start") //nolint:errcheck // known flag - endStr, _ := cmd.Flags().GetString("end") //nolint:errcheck // known flag - - startTime, err := parseTimeFlag(startStr) - if err != nil { - return err - } - endTime, err := parseTimeFlag(endStr) + startTime, endTime, err := resolveCandleTimeRange(cmd) if err != nil { return err } - // Default: last 24 hours. - if startTime == 0 { - startTime = time.Now().Add(-24 * time.Hour).UnixMilli() - } - if endTime == 0 { - endTime = time.Now().UnixMilli() - } - if cfg.DryRun { - req := info.CandleSnapshotRequest{ - Type: "candleSnapshot", - Req: info.CandleSnapshotReq{ - Coin: coin, - Interval: interval, - StartTime: startTime, - EndTime: endTime, - }, - } - return printResult(cmd, cfg, mustMarshal(req), nil) + return printResult(cmd, cfg, mustMarshal(info.CandleSnapshotRequest{Type: "candleSnapshot", Req: info.CandleSnapshotReq{Coin: coin, Interval: interval, StartTime: startTime, EndTime: endTime}}), nil) } - raw, err := ic.CandleSnapshot(cmd.Context(), coin, interval, startTime, endTime) + raw, err := buildInfoClient(cfg).CandleSnapshot(cmd.Context(), coin, interval, startTime, endTime) if err != nil { return err } @@ -281,3 +248,24 @@ func newInfoCandlesCmd() *cobra.Command { cmd.Flags().String("end", "", "end time (Unix ms or ISO 8601)") return cmd } + +func resolveCandleTimeRange(cmd *cobra.Command) (int64, int64, error) { + startText, _ := cmd.Flags().GetString("start") //nolint:errcheck // known flag + endText, _ := cmd.Flags().GetString("end") //nolint:errcheck // known flag + + startTime, err := parseTimeFlag(startText) + if err != nil { + return 0, 0, err + } + endTime, err := parseTimeFlag(endText) + if err != nil { + return 0, 0, err + } + if startTime == 0 { + startTime = time.Now().Add(-24 * time.Hour).UnixMilli() + } + if endTime == 0 { + endTime = time.Now().UnixMilli() + } + return startTime, endTime, nil +} diff --git a/cmd/info_user.go b/cmd/info_user.go index f035c98..56f51fa 100644 --- a/cmd/info_user.go +++ b/cmd/info_user.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "strconv" "github.com/spf13/cobra" @@ -15,27 +16,18 @@ func newInfoStateCmd() *cobra.Command { Short: "Get perp clearinghouse state (positions, margins)", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag //nolint:errcheck // known flag - dex, _ := cmd.Flags().GetString("dex") //nolint:errcheck // known flag + dex, _ := cmd.Flags().GetString("dex") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.ClearinghouseStateRequest{ - Type: "clearinghouseState", User: user, Dex: dex, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.ClearinghouseStateRequest{Type: "clearinghouseState", User: user, Dex: dex}), nil) } - ic := buildInfoClient(cfg) - raw, err := ic.ClearinghouseState(cmd.Context(), user, dex) - if err != nil { - return err - } - - result, err := info.ParseStateResult(raw) + raw, result, err := fetchPerpState(cmd.Context(), cfg, user, dex) if err != nil { return err } @@ -43,7 +35,6 @@ func newInfoStateCmd() *cobra.Command { }, } cmd.Flags().String("address", "", "user address (default: derived from configured private key)") - cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -53,21 +44,17 @@ func newInfoSpotStateCmd() *cobra.Command { Short: "Get spot clearinghouse state", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.SpotClearinghouseStateRequest{ - Type: "spotClearinghouseState", User: user, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.SpotClearinghouseStateRequest{Type: "spotClearinghouseState", User: user}), nil) } - ic := buildInfoClient(cfg) - raw, err := ic.SpotClearinghouseState(cmd.Context(), user) + raw, err := buildInfoClient(cfg).SpotClearinghouseState(cmd.Context(), user) if err != nil { return err } @@ -84,27 +71,18 @@ func newInfoOpenOrdersCmd() *cobra.Command { Short: "Get open orders for a user", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - dex, _ := cmd.Flags().GetString("dex") //nolint:errcheck // known flag + dex, _ := cmd.Flags().GetString("dex") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.FrontendOpenOrdersRequest{ - Type: "frontendOpenOrders", User: user, Dex: dex, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.FrontendOpenOrdersRequest{Type: "frontendOpenOrders", User: user, Dex: dex}), nil) } - ic := buildInfoClient(cfg) - raw, err := ic.FrontendOpenOrders(cmd.Context(), user, dex) - if err != nil { - return err - } - - result, err := info.ParseOpenOrdersResult(raw) + raw, result, err := fetchOpenOrders(cmd, cfg, user, dex) if err != nil { return err } @@ -112,7 +90,6 @@ func newInfoOpenOrdersCmd() *cobra.Command { }, } cmd.Flags().String("address", "", "user address (default: derived from configured private key)") - cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -122,9 +99,8 @@ func newInfoFillsCmd() *cobra.Command { Short: "Get fill history for a user", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } @@ -138,54 +114,24 @@ func newInfoFillsCmd() *cobra.Command { aggregateByTimePtr = &aggregateByTime } - ic := buildInfoClient(cfg) - - // Use time-based endpoint when start or end is specified. + request := info.UserFillsRequest{Type: "userFills", User: user, AggregateByTime: aggregateByTimePtr} if startStr != "" || endStr != "" { - startTime, err := parseTimeFlag(startStr) - if err != nil { - return err - } - endTime, err := parseTimeFlag(endStr) - if err != nil { + request.Type = "userFillsByTime" + if request.StartTime, err = parseTimeFlag(startStr); err != nil { return err } - - if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.UserFillsRequest{ - Type: "userFillsByTime", - User: user, - StartTime: startTime, - EndTime: endTime, - AggregateByTime: aggregateByTimePtr, - }), nil) - } - - raw, err := ic.UserFillsByTime(cmd.Context(), user, startTime, endTime, aggregateByTimePtr) - if err != nil { - return err - } - - result, err := info.ParseFillsResult(raw) - if err != nil { + if request.EndTime, err = parseTimeFlag(endStr); err != nil { return err } - return printResult(cmd, cfg, raw, result) } - if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.UserFillsRequest{ - Type: "userFills", - User: user, - AggregateByTime: aggregateByTimePtr, - }), nil) + return printResult(cmd, cfg, mustMarshal(request), nil) } - raw, err := ic.UserFills(cmd.Context(), user, aggregateByTimePtr) + raw, err := fetchUserFillsRaw(cmd.Context(), buildInfoClient(cfg), user, request) if err != nil { return err } - result, err := info.ParseFillsResult(raw) if err != nil { return err @@ -200,6 +146,13 @@ func newInfoFillsCmd() *cobra.Command { return cmd } +func fetchUserFillsRaw(ctx context.Context, ic *info.InfoClient, user string, request info.UserFillsRequest) ([]byte, error) { + if request.Type == "userFillsByTime" { + return ic.UserFillsByTime(ctx, user, request.StartTime, request.EndTime, request.AggregateByTime) + } + return ic.UserFills(ctx, user, request.AggregateByTime) +} + func newInfoOrderStatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "order-status ", @@ -207,9 +160,8 @@ func newInfoOrderStatusCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } @@ -223,13 +175,10 @@ func newInfoOrderStatusCmd() *cobra.Command { } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.OrderStatusRequest{ - Type: "orderStatus", User: user, Oid: oid, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.OrderStatusRequest{Type: "orderStatus", User: user, Oid: oid}), nil) } - ic := buildInfoClient(cfg) - raw, err := ic.OrderStatus(cmd.Context(), user, oid) + raw, err := buildInfoClient(cfg).OrderStatus(cmd.Context(), user, oid) if err != nil { return err } @@ -246,21 +195,17 @@ func newInfoRateLimitCmd() *cobra.Command { Short: "Get rate limit info for a user", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - addr, _ := cmd.Flags().GetString("address") //nolint:errcheck // known flag - user, err := info.ResolveUserAddress(addr, cfg) + user, err := resolveAddressFlagUser(cmd, cfg) if err != nil { return err } if cfg.DryRun { - return printResult(cmd, cfg, mustMarshal(info.UserRateLimitRequest{ - Type: "userRateLimit", User: user, - }), nil) + return printResult(cmd, cfg, mustMarshal(info.UserRateLimitRequest{Type: "userRateLimit", User: user}), nil) } - ic := buildInfoClient(cfg) - raw, err := ic.UserRateLimit(cmd.Context(), user) + raw, err := buildInfoClient(cfg).UserRateLimit(cmd.Context(), user) if err != nil { return err } diff --git a/cmd/order.go b/cmd/order.go index 00a2dbe..513d3cd 100644 --- a/cmd/order.go +++ b/cmd/order.go @@ -3,18 +3,12 @@ package cmd import "github.com/spf13/cobra" func newOrderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "order", - Short: "Place, cancel, and manage orders", - Long: `Submit limit and market orders, cancel by OID or CLOID, modify existing + return newHelpCommandGroup( + "order", + "Place, cancel, and manage orders", + `Submit limit and market orders, cancel by OID or CLOID, modify existing orders, and batch-place from file. All order commands sign with the configured private key via the L1 phantom agent path.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand( newOrderPlaceCmd(), newOrderMarketCmd(), newOrderCancelCmd(), @@ -23,6 +17,4 @@ private key via the L1 phantom agent path.`, newOrderBatchCmd(), newOrderScheduleCancelCmd(), ) - - return cmd } diff --git a/cmd/order_batch.go b/cmd/order_batch.go index 6c3945f..4661452 100644 --- a/cmd/order_batch.go +++ b/cmd/order_batch.go @@ -4,16 +4,13 @@ import ( "encoding/json" "os" "strings" - "time" - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" "github.com/timbrinded/hlgo/pkg/output" - "github.com/timbrinded/hlgo/pkg/resolver" "github.com/timbrinded/hlgo/pkg/wire" ) @@ -34,135 +31,29 @@ func newOrderBatchCmd() *cobra.Command { Short: "Place multiple orders from a JSON file", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - filePath, _ := cmd.Flags().GetString("file") //nolint:errcheck // known flag - builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag - builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag + filePath, _ := cmd.Flags().GetString("file") //nolint:errcheck // known flag - data, err := os.ReadFile(filePath) + params, err := loadBatchOrderParams(cmd, cfg, filePath) if err != nil { - return output.NewCLIError(output.ErrValidation, "failed to read batch file"). - WithDetails("path", filePath). - WithDetails("cause", err.Error()) - } - - var entries []batchEntry - if err := json.Unmarshal(data, &entries); err != nil { - return output.NewCLIError(output.ErrValidation, "invalid batch file JSON"). - WithDetails("path", filePath). - WithDetails("cause", err.Error()) - } - - if len(entries) == 0 { - return output.NewCLIError(output.ErrValidation, "batch file contains no orders"). - WithDetails("path", filePath) - } - - changedBuilder := cmd.Flags().Changed("builder") - changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") - if changedBuilder != changedBuilderFee { - return output.NewCLIError(output.ErrValidation, "--builder and --builder-fee-tenths-bp must be provided together") - } - - var builder *exchange.BuilderInfo - if changedBuilder { - if !common.IsHexAddress(builderAddr) { - return output.NewCLIError(output.ErrValidation, "invalid builder address"). - WithDetails("builder", builderAddr) - } - if builderFeeTenthsBp < 0 { - return output.NewCLIError(output.ErrValidation, "builder fee must be non-negative"). - WithDetails("builder_fee_tenths_bp", builderFeeTenthsBp) - } - builder = &exchange.BuilderInfo{ - B: strings.ToLower(builderAddr), - F: builderFeeTenthsBp, - } + return err } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms + builder, err := parseOptionalBuilder(cmd) + if err != nil { + return err } - - // Resolve and validate each entry. - r := buildBatchResolver(cfg) - var params []exchange.OrderParams - for i, e := range entries { - side := strings.ToLower(e.Side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "invalid side in batch entry"). - WithDetails("index", i). - WithDetails("value", e.Side) - } - - tifKey := strings.ToLower(e.Tif) - if tifKey == "" { - tifKey = "gtc" - } - wireTif, ok := tifMap[tifKey] - if !ok { - return output.NewCLIError(output.ErrValidation, "invalid tif in batch entry"). - WithDetails("index", i). - WithDetails("value", e.Tif) - } - - price, err := decimal.NewFromString(e.Price) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid price in batch entry"). - WithDetails("index", i). - WithDetails("value", e.Price) - } - size, err := decimal.NewFromString(e.Size) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid size in batch entry"). - WithDetails("index", i). - WithDetails("value", e.Size) - } - - info, err := r.ResolveAsset(cmd.Context(), e.Coin) - if err != nil { - return err - } - - priceStr, err := wire.PriceToWire(price, info.SzDecimals, info.IsSpot) - if err != nil { - return err - } - sizeStr, err := wire.SizeToWire(size, info.SzDecimals) - if err != nil { - return err - } - - params = append(params, exchange.OrderParams{ - AssetID: info.AssetID, - IsBuy: side == "buy", - Price: priceStr, - Size: sizeStr, - ReduceOnly: e.ReduceOnly, - Tif: wireTif, - Cloid: e.Cloid, - }) + expiresAfter, err := parseOptionalExpiresAfter(cmd) + if err != nil { + return err } action := exchange.BuildOrderAction(params, nil, builder) - if cfg.DryRun { return printResult(cmd, cfg, mustMarshal(action), nil) } - exec, err := buildExecutor(cfg) if err != nil { return err } - result, err := exec.PlaceBatchOrders(cmd.Context(), action, expiresAfter) if err != nil { return err @@ -177,15 +68,72 @@ func newOrderBatchCmd() *cobra.Command { cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired("file") + mustMarkRequiredFlags(cmd, "file") return cmd } -// buildBatchResolver creates a resolver for batch entry resolution. -func buildBatchResolver(cfg *config.Config) resolver.Resolver { - c := buildHTTPClient(cfg) - cacheDir := resolveCacheDir(cfg) - return resolver.NewResolver(c, cacheDir, time.Duration(cfg.MetadataTTL)*time.Second) +func loadBatchOrderParams(cmd *cobra.Command, cfg *config.Config, filePath string) ([]exchange.OrderParams, error) { + data, err := os.ReadFile(filePath) + var entries []batchEntry + if err != nil { + return nil, output.NewCLIError(output.ErrValidation, "failed to read batch file"). + WithDetails("path", filePath). + WithDetails("cause", err.Error()) + } else if err := json.Unmarshal(data, &entries); err != nil { + return nil, output.NewCLIError(output.ErrValidation, "invalid batch file JSON"). + WithDetails("path", filePath). + WithDetails("cause", err.Error()) + } else if len(entries) == 0 { + return nil, output.NewCLIError(output.ErrValidation, "batch file contains no orders"). + WithDetails("path", filePath) + } + + r := buildResolver(cfg) + params := make([]exchange.OrderParams, 0, len(entries)) + for i, entry := range entries { + side := strings.ToLower(strings.TrimSpace(entry.Side)) + if side != "buy" && side != "sell" { + return nil, batchEntryValidationError("invalid side in batch entry", i, entry.Side) + } + if entry.Tif == "" { + entry.Tif = "gtc" + } + wireTIF, ok := resolveOrderTIF(entry.Tif) + if !ok { + return nil, batchEntryValidationError("invalid tif in batch entry", i, entry.Tif) + } + + param := exchange.OrderParams{IsBuy: side == "buy", ReduceOnly: entry.ReduceOnly, Tif: wireTIF, Cloid: entry.Cloid} + price, err := decimal.NewFromString(entry.Price) + if err != nil { + return nil, batchEntryValidationError("invalid price in batch entry", i, entry.Price) + } + size, err := decimal.NewFromString(entry.Size) + if err != nil { + return nil, batchEntryValidationError("invalid size in batch entry", i, entry.Size) + } + + assetInfo, err := r.ResolveAsset(cmd.Context(), entry.Coin) + if err != nil { + return nil, err + } + param.AssetID = assetInfo.AssetID + param.Price, err = wire.PriceToWire(price, assetInfo.SzDecimals, assetInfo.IsSpot) + if err != nil { + return nil, err + } + param.Size, err = wire.SizeToWire(size, assetInfo.SzDecimals) + if err != nil { + return nil, err + } + params = append(params, param) + } + return params, nil +} + +func batchEntryValidationError(message string, index int, value string) error { + return output.NewCLIError(output.ErrValidation, message). + WithDetails("index", index). + WithDetails("value", value) } diff --git a/cmd/order_cancel.go b/cmd/order_cancel.go index ac46d53..62e8a78 100644 --- a/cmd/order_cancel.go +++ b/cmd/order_cancel.go @@ -3,15 +3,12 @@ package cmd import ( "strconv" - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" - "github.com/timbrinded/hlgo/pkg/client" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" "github.com/timbrinded/hlgo/pkg/info" "github.com/timbrinded/hlgo/pkg/output" - "github.com/timbrinded/hlgo/pkg/resolver" ) func newOrderCancelCmd() *cobra.Command { @@ -20,67 +17,43 @@ func newOrderCancelCmd() *cobra.Command { Short: "Cancel an order by OID or CLOID", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag - cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag + cloid, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - // Mutual exclusion: exactly one of --oid or --cloid. - if oidStr == "" && cloidStr == "" { + if oidStr == "" && cloid == "" { return output.NewCLIError(output.ErrValidation, "one of --oid or --cloid is required") } - if oidStr != "" && cloidStr != "" { + if oidStr != "" && cloid != "" { return output.NewCLIError(output.ErrValidation, "--oid and --cloid are mutually exclusive") } - exec, err := buildExecutor(cfg) + expiresAfter, err := parseOptionalExpiresAfter(cmd) if err != nil { return err } - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms - } - - if cloidStr != "" { - // Cancel by CLOID — resolve coin to asset ID. - assetID, err := resolveAssetID(cmd, cfg, coin) - if err != nil { - return err - } - - result, err := exec.CancelByCloid(cmd.Context(), []exchange.CancelByCloidWire{ - {Asset: assetID, Cloid: cloidStr}, - }, cfg.DryRun, expiresAfter) - if err != nil { - return err - } - return printResult(cmd, cfg, result, nil) - } - - // Cancel by OID. - oid, err := strconv.ParseUint(oidStr, 10, 64) + exec, err := buildExecutor(cfg) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid OID: must be numeric"). - WithDetails("value", oidStr) + return err } - assetID, err := resolveAssetID(cmd, cfg, coin) + assetInfo, err := buildResolver(cfg).ResolveAsset(cmd.Context(), coin) if err != nil { return err } - result, err := exec.CancelOrders(cmd.Context(), []exchange.CancelWire{ - {A: assetID, O: oid}, - }, cfg.DryRun, expiresAfter) + var result []byte + if cloid != "" { + result, err = exec.CancelByCloid(cmd.Context(), []exchange.CancelByCloidWire{{Asset: assetInfo.AssetID, Cloid: cloid}}, cfg.DryRun, expiresAfter) + } else { + oid, parseErr := strconv.ParseUint(oidStr, 10, 64) + if parseErr != nil { + return output.NewCLIError(output.ErrValidation, "invalid OID: must be numeric"). + WithDetails("value", oidStr) + } + result, err = exec.CancelOrders(cmd.Context(), []exchange.CancelWire{{A: assetInfo.AssetID, O: oid}}, cfg.DryRun, expiresAfter) + } if err != nil { return err } @@ -93,8 +66,7 @@ func newOrderCancelCmd() *cobra.Command { cmd.Flags().String("cloid", "", "client order ID to cancel") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired("coin") + mustMarkRequiredFlags(cmd, "coin") return cmd } @@ -105,82 +77,43 @@ func newOrderCancelAllCmd() *cobra.Command { Short: "Cancel all open orders (optionally for a specific coin)", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag - - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - - // Resolve user address for fetching open orders. - addr := onBehalfOf - if addr == "" { - var err error - addr, err = info.ResolveUserAddress("", cfg) - if err != nil { - return err - } - } + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag - // Fetch all open orders. - ic := buildInfoClient(cfg) - raw, err := ic.FrontendOpenOrders(cmd.Context(), addr, cfg.Dex) + addr, err := info.ResolveUserAddress(onBehalfOf, cfg) if err != nil { return err } - orders, err := info.ParseOpenOrdersResult(raw) + _, orders, err := fetchOpenOrders(cmd, cfg, addr, cfg.Dex) if err != nil { return err + } else if len(orders) == 0 { + return printOKMessage(cmd, cfg, "no open orders to cancel") } - if len(orders) == 0 { - return printResult(cmd, cfg, mustMarshal(map[string]string{ - "status": "ok", "message": "no open orders to cancel", - }), nil) - } - - exec, err := buildExecutor(cfg) - if err != nil { - return err - } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms - } - - // Build cancel list, optionally filtered by coin. - var cancels []exchange.CancelWire - for _, o := range orders { - if coin != "" && o.Coin != coin { + cancels := make([]exchange.CancelWire, 0, len(orders)) + for _, order := range orders { + if coin != "" && order.Coin != coin { continue } - if o.Oid < 0 { - return output.NewCLIError(output.ErrAPI, "open order returned negative OID"). - WithDetails("coin", o.Coin). - WithDetails("oid", o.Oid) - } - assetID, err := resolveAssetID(cmd, cfg, o.Coin) + cancel, err := cancelWireForOpenOrder(cmd, cfg, order) if err != nil { return err } - cancels = append(cancels, exchange.CancelWire{A: assetID, O: uint64(o.Oid)}) + cancels = append(cancels, cancel) } - if len(cancels) == 0 { - return printResult(cmd, cfg, mustMarshal(map[string]string{ - "status": "ok", "message": "no matching orders to cancel", - }), nil) + return printOKMessage(cmd, cfg, "no matching orders to cancel") + } + + expiresAfter, err := parseOptionalExpiresAfter(cmd) + if err != nil { + return err + } + exec, err := buildExecutor(cfg) + if err != nil { + return err } result, err := exec.CancelOrders(cmd.Context(), cancels, cfg.DryRun, expiresAfter) @@ -198,19 +131,16 @@ func newOrderCancelAllCmd() *cobra.Command { return cmd } -// resolveAssetID resolves a coin name to its integer asset ID. -func resolveAssetID(cmd *cobra.Command, cfg *config.Config, coin string) (int, error) { - c := buildHTTPClient(cfg) - cacheDir := resolveCacheDir(cfg) - r := resolver.NewResolver(c, cacheDir, 0) - info, err := r.ResolveAsset(cmd.Context(), coin) - if err != nil { - return 0, err +func cancelWireForOpenOrder(cmd *cobra.Command, cfg *config.Config, order info.OpenOrder) (exchange.CancelWire, error) { + if order.Oid < 0 { + return exchange.CancelWire{}, output.NewCLIError(output.ErrAPI, "open order returned negative OID"). + WithDetails("coin", order.Coin). + WithDetails("oid", order.Oid) } - return info.AssetID, nil -} -// buildHTTPClient creates a raw HTTP client for the current config. -func buildHTTPClient(cfg *config.Config) *client.Client { - return client.NewClient(baseURL(cfg)) + assetInfo, err := buildResolver(cfg).ResolveAsset(cmd.Context(), order.Coin) + if err != nil { + return exchange.CancelWire{}, err + } + return exchange.CancelWire{A: assetInfo.AssetID, O: uint64(order.Oid)}, nil } diff --git a/cmd/order_market.go b/cmd/order_market.go index fc43579..ebba134 100644 --- a/cmd/order_market.go +++ b/cmd/order_market.go @@ -1,17 +1,34 @@ package cmd import ( - "strings" - - "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" ) +func buildPlaceMarketOrderInput(cmd *cobra.Command, cfg *config.Config, coin, side, sizeStr, slippageStr string) (exchange.PlaceMarketOrderInput, error) { + var err error + input := exchange.PlaceMarketOrderInput{Coin: coin, Side: side, DryRun: cfg.DryRun} + input.Builder, err = parseOptionalBuilder(cmd) + if err != nil { + return exchange.PlaceMarketOrderInput{}, err + } + input.ExpiresAfter, err = parseOptionalExpiresAfter(cmd) + if err != nil { + return exchange.PlaceMarketOrderInput{}, err + } + input.Size, err = parseDecimalField("size", sizeStr) + if err != nil { + return exchange.PlaceMarketOrderInput{}, err + } + input.SlippagePercent, err = parseDecimalField("slippage", slippageStr) + if err != nil { + return exchange.PlaceMarketOrderInput{}, err + } + return input, nil +} + func newOrderMarketCmd() *cobra.Command { cmd := &cobra.Command{ Use: "market", @@ -20,72 +37,14 @@ func newOrderMarketCmd() *cobra.Command { The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted price.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag - slippageStr, _ := cmd.Flags().GetString("slippage") //nolint:errcheck // known flag - builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag - builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag - - side = strings.ToLower(side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", side) - } - - changedBuilder := cmd.Flags().Changed("builder") - changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") - if changedBuilder != changedBuilderFee { - return output.NewCLIError(output.ErrValidation, "--builder and --builder-fee-tenths-bp must be provided together") - } + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag + slippageStr, _ := cmd.Flags().GetString("slippage") //nolint:errcheck // known flag - var builder *exchange.BuilderInfo - if changedBuilder { - if !common.IsHexAddress(builderAddr) { - return output.NewCLIError(output.ErrValidation, "invalid builder address"). - WithDetails("builder", builderAddr) - } - if builderFeeTenthsBp < 0 { - return output.NewCLIError(output.ErrValidation, "builder fee must be non-negative"). - WithDetails("builder_fee_tenths_bp", builderFeeTenthsBp) - } - builder = &exchange.BuilderInfo{ - B: strings.ToLower(builderAddr), - F: builderFeeTenthsBp, - } - } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms - } - - size, err := decimal.NewFromString(sizeStr) + input, err := buildPlaceMarketOrderInput(cmd, cfg, coin, side, sizeStr, slippageStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid size"). - WithDetails("value", sizeStr) - } - - slippageDecimal, err := decimal.NewFromString(slippageStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid slippage"). - WithDetails("value", slippageStr) - } - if slippageDecimal.IsNegative() { - return output.NewCLIError(output.ErrValidation, "slippage must be non-negative"). - WithDetails("value", slippageStr) - } - if slippageDecimal.GreaterThanOrEqual(decimal.NewFromInt(100)) { - return output.NewCLIError(output.ErrValidation, "slippage must be less than 100 percent"). - WithDetails("value", slippageStr) + return err } exec, err := buildExecutor(cfg) @@ -93,15 +52,7 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri return err } - result, err := exec.PlaceMarketOrder(cmd.Context(), exchange.PlaceMarketOrderInput{ - Coin: coin, - Side: side, - Size: size, - SlippagePercent: slippageDecimal, - Builder: builder, - ExpiresAfter: expiresAfter, - DryRun: cfg.DryRun, - }) + result, err := exec.PlaceMarketOrder(cmd.Context(), input) if err != nil { return err } @@ -118,10 +69,7 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - for _, required := range []string{"coin", "side", "size"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "side", "size") return cmd } diff --git a/cmd/order_modify.go b/cmd/order_modify.go index c2ea09c..688ec0e 100644 --- a/cmd/order_modify.go +++ b/cmd/order_modify.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -20,98 +19,37 @@ func newOrderModifyCmd() *cobra.Command { Short: "Modify an existing order", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag - sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag - tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag - reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag - - side = strings.ToLower(side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", side) - } - - oid, err := strconv.ParseUint(oidStr, 10, 64) + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag + sideFlag, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag + sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag + tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag + reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + + input, err := parseModifyBaseInput(coin, oidStr, sideFlag, tifFlag, reduce, cfg.DryRun) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid OID: must be numeric"). - WithDetails("value", oidStr) - } - - wireTif, ok := tifMap[strings.ToLower(tifFlag)] - if !ok { - return output.NewCLIError(output.ErrValidation, "invalid tif: "+tifFlag). - WithDetails("value", tifFlag). - WithDetails("valid", "gtc, ioc, alo") - } - - hasPrice := cmd.Flags().Changed("price") - hasSize := cmd.Flags().Changed("size") - if !hasPrice && !hasSize { - return output.NewCLIError(output.ErrValidation, "at least one of --price or --size is required") - } - - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - - if !hasPrice || !hasSize { - existing, err := lookupOpenOrderByOID(cmd, cfg, oid, coin, onBehalfOf) - if err != nil { - return err - } - if !hasPrice { - priceStr = existing.LimitPx - } - if !hasSize { - sizeStr = existing.Sz - } + return err } - - price, err := decimal.NewFromString(priceStr) + priceStr, sizeStr, err = backfillModifyPriceSize(cmd, cfg, input.Oid, coin, onBehalfOf, priceStr, sizeStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid price"). - WithDetails("value", priceStr) + return err } - size, err := decimal.NewFromString(sizeStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid size"). - WithDetails("value", sizeStr) + if input.Price, err = parseDecimalField("price", priceStr); err != nil { + return err } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms + if input.Size, err = parseDecimalField("size", sizeStr); err != nil { + return err + } + if input.ExpiresAfter, err = parseOptionalExpiresAfter(cmd); err != nil { + return err } - exec, err := buildExecutor(cfg) if err != nil { return err } - - result, err := exec.ModifyOrder(cmd.Context(), exchange.ModifyOrderInput{ - Coin: coin, - Oid: oid, - Side: side, - Price: price, - Size: size, - Tif: wireTif, - ReduceOnly: reduce, - ExpiresAfter: expiresAfter, - DryRun: cfg.DryRun, - }) + result, err := exec.ModifyOrder(cmd.Context(), input) if err != nil { return err } @@ -130,37 +68,67 @@ func newOrderModifyCmd() *cobra.Command { cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - for _, required := range []string{"coin", "oid", "side"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "oid", "side") return cmd } -func lookupOpenOrderByOID(cmd *cobra.Command, cfg *config.Config, oid uint64, coin string, onBehalfOf string) (*info.OpenOrder, error) { - addr := onBehalfOf - if addr == "" { - var err error - addr, err = info.ResolveUserAddress("", cfg) +func parseModifyBaseInput(coin, oidStr, sideFlag, tifFlag string, reduce, dryRun bool) (exchange.ModifyOrderInput, error) { + var err error + input := exchange.ModifyOrderInput{Coin: coin, ReduceOnly: reduce, DryRun: dryRun} + input.Side, err = parseOrderSide(sideFlag) + if err != nil { + return exchange.ModifyOrderInput{}, err + } + input.Oid, err = strconv.ParseUint(oidStr, 10, 64) + if err != nil { + return exchange.ModifyOrderInput{}, output.NewCLIError(output.ErrValidation, "invalid OID: must be numeric"). + WithDetails("value", oidStr) + } + input.Tif, err = parseOrderTIF(tifFlag) + if err != nil { + return exchange.ModifyOrderInput{}, err + } + return input, nil +} + +func backfillModifyPriceSize(cmd *cobra.Command, cfg *config.Config, oid uint64, coin, onBehalfOf, priceStr, sizeStr string) (string, string, error) { + hasPrice, hasSize := cmd.Flags().Changed("price"), cmd.Flags().Changed("size") + if !hasPrice && !hasSize { + return "", "", output.NewCLIError(output.ErrValidation, "at least one of --price or --size is required") + } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return "", "", output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } + if !hasPrice || !hasSize { + existing, err := lookupOpenOrderByOID(cmd, cfg, oid, coin, onBehalfOf) if err != nil { - return nil, err + return "", "", err + } + if !hasPrice { + priceStr = existing.LimitPx + } + if !hasSize { + sizeStr = existing.Sz } } + return priceStr, sizeStr, nil +} + +func lookupOpenOrderByOID(cmd *cobra.Command, cfg *config.Config, oid uint64, coin string, onBehalfOf string) (*info.OpenOrder, error) { + addr, err := info.ResolveUserAddress(onBehalfOf, cfg) + if err != nil { + return nil, err + } - ic := buildInfoClient(cfg) dex := cfg.Dex if dex == "" { if idx := strings.Index(coin, ":"); idx > 0 { dex = strings.ToLower(strings.TrimSpace(coin[:idx])) } } - raw, err := ic.FrontendOpenOrders(cmd.Context(), addr, dex) - if err != nil { - return nil, err - } - - orders, err := info.ParseOpenOrdersResult(raw) + _, orders, err := fetchOpenOrders(cmd, cfg, addr, dex) if err != nil { return nil, err } diff --git a/cmd/order_place.go b/cmd/order_place.go index 1822f6c..7810b9d 100644 --- a/cmd/order_place.go +++ b/cmd/order_place.go @@ -1,27 +1,40 @@ package cmd import ( - "os" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/spf13/cobra" - "github.com/timbrinded/hlgo/pkg/client" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" - "github.com/timbrinded/hlgo/pkg/resolver" - "github.com/timbrinded/hlgo/pkg/signer" ) -// tifMap maps user-friendly TIF strings to wire-format values. -var tifMap = map[string]string{ - "gtc": "Gtc", - "ioc": "Ioc", - "alo": "Alo", +func buildPlaceOrderInput(cmd *cobra.Command, coin, sideFlag, priceStr, sizeStr, tifFlag string, reduce bool, cloidStr string, dryRun bool) (exchange.PlaceOrderInput, error) { + var err error + input := exchange.PlaceOrderInput{Coin: coin, ReduceOnly: reduce, Cloid: stringPointer(cloidStr), DryRun: dryRun} + input.Side, err = parseOrderSide(sideFlag) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + input.Tif, err = parseOrderTIF(tifFlag) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + input.Price, err = parseDecimalField("price", priceStr) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + input.Size, err = parseDecimalField("size", sizeStr) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + input.Builder, err = parseOptionalBuilder(cmd) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + input.ExpiresAfter, err = parseOptionalExpiresAfter(cmd) + if err != nil { + return exchange.PlaceOrderInput{}, err + } + return input, nil } func newOrderPlaceCmd() *cobra.Command { @@ -30,81 +43,17 @@ func newOrderPlaceCmd() *cobra.Command { Short: "Place a limit order", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag - sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag - tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag - reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag - cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag - builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag - expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag - - // Validate side. - side = strings.ToLower(side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", side) - } - - // Map TIF. - wireTif, ok := tifMap[strings.ToLower(tifFlag)] - if !ok { - return output.NewCLIError(output.ErrValidation, "invalid tif: "+tifFlag). - WithDetails("value", tifFlag). - WithDetails("valid", "gtc, ioc, alo") - } - - // Parse price and size with decimal precision. - price, err := decimal.NewFromString(priceStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid price"). - WithDetails("value", priceStr) - } - size, err := decimal.NewFromString(sizeStr) + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + sideFlag, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + priceStr, _ := cmd.Flags().GetString("price") //nolint:errcheck // known flag + sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag + tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag + reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag + cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag + + input, err := buildPlaceOrderInput(cmd, coin, sideFlag, priceStr, sizeStr, tifFlag, reduce, cloidStr, cfg.DryRun) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid size"). - WithDetails("value", sizeStr) - } - - var cloid *string - if cloidStr != "" { - cloid = &cloidStr - } - - changedBuilder := cmd.Flags().Changed("builder") - changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") - if changedBuilder != changedBuilderFee { - return output.NewCLIError(output.ErrValidation, "--builder and --builder-fee-tenths-bp must be provided together") - } - - var builder *exchange.BuilderInfo - if changedBuilder { - if !common.IsHexAddress(builderAddr) { - return output.NewCLIError(output.ErrValidation, "invalid builder address"). - WithDetails("builder", builderAddr) - } - if builderFeeTenthsBp < 0 { - return output.NewCLIError(output.ErrValidation, "builder fee must be non-negative"). - WithDetails("builder_fee_tenths_bp", builderFeeTenthsBp) - } - builder = &exchange.BuilderInfo{ - B: strings.ToLower(builderAddr), - F: builderFeeTenthsBp, - } - } - - var expiresAfter *int64 - if expiresAfterStr != "" { - ms, err := parseTimeFlag(expiresAfterStr) - if err != nil { - return err - } - if ms <= 0 { - return output.NewCLIError(output.ErrValidation, "expires-after must be a positive Unix ms timestamp") - } - expiresAfter = &ms + return err } exec, err := buildExecutor(cfg) @@ -112,18 +61,7 @@ func newOrderPlaceCmd() *cobra.Command { return err } - result, err := exec.PlaceOrder(cmd.Context(), exchange.PlaceOrderInput{ - Coin: coin, - Side: side, - Price: price, - Size: size, - Tif: wireTif, - ReduceOnly: reduce, - Cloid: cloid, - Builder: builder, - ExpiresAfter: expiresAfter, - DryRun: cfg.DryRun, - }) + result, err := exec.PlaceOrder(cmd.Context(), input) if err != nil { return err } @@ -143,43 +81,7 @@ func newOrderPlaceCmd() *cobra.Command { cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") - for _, required := range []string{"coin", "side", "price", "size"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "side", "price", "size") return cmd } - -// buildExecutor constructs an exchange.Executor from the current config. -func buildExecutor(cfg *config.Config) (*exchange.Executor, error) { - keyHex := os.Getenv(cfg.PrivateKeyEnv) - if keyHex == "" { - return nil, output.NewCLIError(output.ErrConfig, "private key not set"). - WithDetails("env_var", cfg.PrivateKeyEnv) - } - - s, err := signer.NewSigner(keyHex) - if err != nil { - return nil, err - } - - c := client.NewClient(baseURL(cfg)) - cacheDir := resolveCacheDir(cfg) - r := resolver.NewResolver(c, cacheDir, time.Duration(cfg.MetadataTTL)*time.Second) - - return exchange.NewExecutor(s, c, r, !cfg.Testnet), nil -} - -// resolveCacheDir returns the cache directory path for the current network. -func resolveCacheDir(cfg *config.Config) string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - network := "mainnet" - if cfg.Testnet { - network = "testnet" - } - return home + "/.hlgo/cache/" + network -} diff --git a/cmd/order_schedule_cancel.go b/cmd/order_schedule_cancel.go index 8654733..5dd5fb8 100644 --- a/cmd/order_schedule_cancel.go +++ b/cmd/order_schedule_cancel.go @@ -18,11 +18,9 @@ func newOrderScheduleCancelCmd() *cobra.Command { or clear an existing schedule. Exactly one of --timeout or --clear must be provided.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - timeoutStr, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag - clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag + clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag hasTimeout := cmd.Flags().Changed("timeout") - if hasTimeout && clear { return output.NewCLIError(output.ErrValidation, "--timeout and --clear are mutually exclusive") } @@ -30,36 +28,18 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi return output.NewCLIError(output.ErrValidation, "one of --timeout or --clear is required") } - var cancelTime *int64 - if hasTimeout { - d, err := time.ParseDuration(timeoutStr) - if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid timeout duration"). - WithDetails("value", timeoutStr). - WithDetails("hint", "use Go duration format (e.g. 5m, 1h, 30s)") - } - if d <= 0 { - return output.NewCLIError(output.ErrValidation, "timeout must be positive"). - WithDetails("value", timeoutStr) - } - if d < 5*time.Second { - return output.NewCLIError(output.ErrValidation, "timeout must be at least 5 seconds"). - WithDetails("value", timeoutStr) - } - ms := time.Now().Add(d).UnixMilli() - cancelTime = &ms + cancelTime, err := parseScheduleCancelTime(cmd, hasTimeout) + if err != nil { + return err } - // When --clear, cancelTime stays nil (which tells API to remove the schedule). exec, err := buildExecutor(cfg) if err != nil { return err } - result, err := exec.ScheduleCancel(cmd.Context(), exchange.ScheduleCancelInput{ - Time: cancelTime, - DryRun: cfg.DryRun, - }) + input := exchange.ScheduleCancelInput{Time: cancelTime, DryRun: cfg.DryRun} + result, err := exec.ScheduleCancel(cmd.Context(), input) if err != nil { return err } @@ -73,3 +53,28 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi return cmd } + +func parseScheduleCancelTime(cmd *cobra.Command, hasTimeout bool) (*int64, error) { + if !hasTimeout { + return nil, nil + } + + timeoutText, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag + timeout, err := time.ParseDuration(timeoutText) + if err != nil { + return nil, output.NewCLIError(output.ErrValidation, "invalid timeout duration"). + WithDetails("value", timeoutText). + WithDetails("hint", "use Go duration format (e.g. 5m, 1h, 30s)") + } + if timeout <= 0 { + return nil, output.NewCLIError(output.ErrValidation, "timeout must be positive"). + WithDetails("value", timeoutText) + } + if timeout < 5*time.Second { + return nil, output.NewCLIError(output.ErrValidation, "timeout must be at least 5 seconds"). + WithDetails("value", timeoutText) + } + + cancelTime := time.Now().Add(timeout).UnixMilli() + return &cancelTime, nil +} diff --git a/cmd/position.go b/cmd/position.go index 8a01f10..3db0ed1 100644 --- a/cmd/position.go +++ b/cmd/position.go @@ -3,21 +3,13 @@ package cmd import "github.com/spf13/cobra" func newPositionCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "position", - Short: "Manage positions: leverage and margin", - Long: `Set leverage mode (cross/isolated) and multiplier, and update isolated + return newHelpCommandGroup( + "position", + "Manage positions: leverage and margin", + `Set leverage mode (cross/isolated) and multiplier, and update isolated margin for open positions. Position commands sign with the configured private key via the L1 phantom agent path.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand( newPositionLeverageCmd(), newPositionMarginCmd(), ) - - return cmd } diff --git a/cmd/position_leverage.go b/cmd/position_leverage.go index acd70c1..be1be88 100644 --- a/cmd/position_leverage.go +++ b/cmd/position_leverage.go @@ -26,22 +26,13 @@ func newPositionLeverageCmd() *cobra.Command { WithDetails("value", mode) } - if leverage < 1 { - return output.NewCLIError(output.ErrValidation, "leverage must be at least 1"). - WithDetails("value", leverage) - } - exec, err := buildExecutor(cfg) if err != nil { return err } - result, err := exec.UpdateLeverage(cmd.Context(), exchange.UpdateLeverageInput{ - Coin: coin, - IsCross: mode == "cross", - Leverage: leverage, - DryRun: cfg.DryRun, - }) + input := exchange.UpdateLeverageInput{Coin: coin, IsCross: mode == "cross", Leverage: leverage, DryRun: cfg.DryRun} + result, err := exec.UpdateLeverage(cmd.Context(), input) if err != nil { return err } @@ -54,10 +45,7 @@ func newPositionLeverageCmd() *cobra.Command { cmd.Flags().Int("leverage", 0, "leverage multiplier (max is asset-specific, API-enforced)") cmd.Flags().String("mode", "cross", "margin mode: cross or isolated") - for _, required := range []string{"coin", "leverage"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "leverage") return cmd } diff --git a/cmd/position_margin.go b/cmd/position_margin.go index 716436d..e0d3594 100644 --- a/cmd/position_margin.go +++ b/cmd/position_margin.go @@ -1,14 +1,10 @@ package cmd import ( - "strings" - - "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" ) func newPositionMarginCmd() *cobra.Command { @@ -18,19 +14,17 @@ func newPositionMarginCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + sideFlag, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - side = strings.ToLower(side) - if side != "buy" && side != "sell" { - return output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", side) + side, err := parseOrderSide(sideFlag) + if err != nil { + return err } - amount, err := decimal.NewFromString(amountStr) + amount, err := parseDecimalField("amount", amountStr) if err != nil { - return output.NewCLIError(output.ErrValidation, "invalid amount"). - WithDetails("value", amountStr) + return err } exec, err := buildExecutor(cfg) @@ -38,12 +32,8 @@ func newPositionMarginCmd() *cobra.Command { return err } - result, err := exec.UpdateIsolatedMargin(cmd.Context(), exchange.UpdateIsolatedMarginInput{ - Coin: coin, - IsBuy: side == "buy", - Amount: amount, - DryRun: cfg.DryRun, - }) + input := exchange.UpdateIsolatedMarginInput{Coin: coin, IsBuy: side == "buy", Amount: amount, DryRun: cfg.DryRun} + result, err := exec.UpdateIsolatedMargin(cmd.Context(), input) if err != nil { return err } @@ -56,10 +46,7 @@ func newPositionMarginCmd() *cobra.Command { cmd.Flags().String("side", "", "position side: buy or sell") cmd.Flags().String("amount", "", "margin amount (positive to add, negative to remove)") - for _, required := range []string{"coin", "side", "amount"} { - //nolint:errcheck // MarkFlagRequired on known flags never fails - cmd.MarkFlagRequired(required) - } + mustMarkRequiredFlags(cmd, "coin", "side", "amount") return cmd } diff --git a/pkg/client/client.go b/pkg/client/client.go index ab7c496..2ca2a13 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -14,7 +14,6 @@ import ( "fmt" "io" "net/http" - "strings" "time" "github.com/timbrinded/hlgo/pkg/output" @@ -42,7 +41,7 @@ type Client struct { // NewClient creates a Client targeting baseURL (e.g. "https://api.hyperliquid.xyz"). // Functional options configure timeout, retries, and other behaviour. func NewClient(baseURL string, opts ...Option) *Client { - c := &Client{ + client := &Client{ baseURL: baseURL, httpClient: http.Client{ Timeout: defaultTimeout, @@ -50,9 +49,9 @@ func NewClient(baseURL string, opts ...Option) *Client { maxRetries: defaultMaxRetries, } for _, opt := range opts { - opt(c) + opt(client) } - return c + return client } // SignatureWire is the structured signature format expected by the /exchange endpoint. @@ -99,111 +98,6 @@ func (c *Client) PostExchange(ctx context.Context, action any, nonce int64, sign return raw, nil } -func validateExchangeResponse(raw json.RawMessage) error { - var envelope struct { - Status string `json:"status"` - Response json.RawMessage `json:"response"` - } - - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&envelope); err != nil { - return output.NewCLIError(output.ErrAPI, "failed to decode exchange response envelope"). - WithDetails("path", "/exchange"). - WithDetails("cause", err.Error()) - } - - if !strings.EqualFold(envelope.Status, "err") { - return validateExchangeStatuses(envelope.Status, envelope.Response) - } - - cliErr := output.NewCLIError(output.ErrAPI, "exchange returned error status"). - WithDetails("path", "/exchange"). - WithDetails("exchange_status", envelope.Status) - - if len(envelope.Response) > 0 { - var responseMessage string - if err := json.Unmarshal(envelope.Response, &responseMessage); err == nil { - cliErr.Message = "exchange error: " + responseMessage - cliErr = cliErr.WithDetails("exchange_response", responseMessage) - } else { - cliErr = cliErr.WithDetails("exchange_response", string(envelope.Response)) - } - } - - return cliErr -} - -func isBenignExchangeStatus(status string) bool { - switch normalized := strings.ToLower(strings.Trim(strings.TrimSpace(status), `"`)); normalized { - case "", "success", "waitingforfill": - return true - default: - return false - } -} - -func validateExchangeStatuses(status string, response json.RawMessage) error { - var payload struct { - Data struct { - Statuses []json.RawMessage `json:"statuses"` - } `json:"data"` - } - if err := json.Unmarshal(response, &payload); err != nil { - return nil - } - if len(payload.Data.Statuses) == 0 { - return nil - } - - var errs []string - for _, entryRaw := range payload.Data.Statuses { - var asString string - if err := json.Unmarshal(entryRaw, &asString); err == nil { - asString = strings.TrimSpace(asString) - if isBenignExchangeStatus(asString) { - continue - } - errs = append(errs, asString) - continue - } - - var asObject map[string]json.RawMessage - if err := json.Unmarshal(entryRaw, &asObject); err != nil { - continue - } - - rawErr, ok := asObject["error"] - if !ok { - continue - } - - var msg string - if err := json.Unmarshal(rawErr, &msg); err == nil && strings.TrimSpace(msg) != "" { - if isBenignExchangeStatus(msg) { - continue - } - errs = append(errs, msg) - continue - } - - rawErrText := strings.TrimSpace(string(rawErr)) - if isBenignExchangeStatus(rawErrText) { - continue - } - errs = append(errs, rawErrText) - } - - if len(errs) == 0 { - return nil - } - - return output.NewCLIError(output.ErrAPI, "exchange action returned error statuses"). - WithDetails("path", "/exchange"). - WithDetails("exchange_status", status). - WithDetails("exchange_errors", errs) -} - // doPost performs a POST request with retry logic for transient errors. // It marshals body to JSON, sends it, and decodes the response using UseNumber. func (c *Client) doPost(ctx context.Context, path string, body any) (json.RawMessage, error) { @@ -273,33 +167,15 @@ func (c *Client) executeRequest(ctx context.Context, url, path string, payload [ WithDetails("cause", err.Error()) } - if resp.StatusCode == http.StatusTooManyRequests { - return nil, &retryableError{ - err: output.NewCLIError(output.ErrRateLimit, "rate limited by API"). - WithDetails("path", path). - WithDetails("status_code", resp.StatusCode), - } - } - - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - return nil, output.NewCLIError(output.ErrAPI, fmt.Sprintf("API error: %s", string(respBody))). - WithDetails("path", path). - WithDetails("status_code", resp.StatusCode) - } - - if resp.StatusCode >= 500 { - return nil, &retryableError{ - err: output.NewCLIError(output.ErrAPI, fmt.Sprintf("server error: %s", string(respBody))). - WithDetails("path", path). - WithDetails("status_code", resp.StatusCode), - } + if err := responseStatusError(path, resp.StatusCode, respBody); err != nil { + return nil, err } // Decode with UseNumber to preserve financial precision. var result json.RawMessage - dec := json.NewDecoder(bytes.NewReader(respBody)) - dec.UseNumber() - if err := dec.Decode(&result); err != nil { + decoder := json.NewDecoder(bytes.NewReader(respBody)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { return nil, output.NewCLIError(output.ErrAPI, "failed to decode response JSON"). WithDetails("path", path). WithDetails("status_code", resp.StatusCode). @@ -310,6 +186,25 @@ func (c *Client) executeRequest(ctx context.Context, url, path string, payload [ return result, nil } +func responseStatusError(path string, statusCode int, body []byte) error { + switch { + case statusCode == http.StatusTooManyRequests: + return &retryableError{err: output.NewCLIError(output.ErrRateLimit, "rate limited by API"). + WithDetails("path", path). + WithDetails("status_code", statusCode)} + case statusCode >= 500: + return &retryableError{err: output.NewCLIError(output.ErrAPI, fmt.Sprintf("server error: %s", string(body))). + WithDetails("path", path). + WithDetails("status_code", statusCode)} + case statusCode >= 400: + return output.NewCLIError(output.ErrAPI, fmt.Sprintf("API error: %s", string(body))). + WithDetails("path", path). + WithDetails("status_code", statusCode) + default: + return nil + } +} + // retryableError wraps an error to signal that the request may be retried. type retryableError struct { err error @@ -320,8 +215,8 @@ func (e *retryableError) Unwrap() error { return e.err } // isRetryable reports whether err signals a transient failure worth retrying. func isRetryable(err error) bool { - var re *retryableError - return errors.As(err, &re) + var retryable *retryableError + return errors.As(err, &retryable) } // recordWeight records API weight and emits a warning if approaching the limit. diff --git a/pkg/client/exchange_response.go b/pkg/client/exchange_response.go new file mode 100644 index 0000000..3140321 --- /dev/null +++ b/pkg/client/exchange_response.go @@ -0,0 +1,114 @@ +package client + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/timbrinded/hlgo/pkg/output" +) + +func validateExchangeResponse(raw json.RawMessage) error { + var envelope struct { + Status string `json:"status"` + Response json.RawMessage `json:"response"` + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&envelope); err != nil { + return output.NewCLIError(output.ErrAPI, "failed to decode exchange response envelope"). + WithDetails("path", "/exchange"). + WithDetails("cause", err.Error()) + } + + if !strings.EqualFold(envelope.Status, "err") { + return validateExchangeStatuses(envelope.Status, envelope.Response) + } + + cliErr := output.NewCLIError(output.ErrAPI, "exchange returned error status"). + WithDetails("path", "/exchange"). + WithDetails("exchange_status", envelope.Status) + + if len(envelope.Response) > 0 { + var responseMessage string + if err := json.Unmarshal(envelope.Response, &responseMessage); err == nil { + cliErr.Message = "exchange error: " + responseMessage + cliErr = cliErr.WithDetails("exchange_response", responseMessage) + } else { + cliErr = cliErr.WithDetails("exchange_response", string(envelope.Response)) + } + } + + return cliErr +} + +func isBenignExchangeStatus(status string) bool { + switch normalized := strings.ToLower(strings.Trim(strings.TrimSpace(status), `"`)); normalized { + case "", "success", "waitingforfill": + return true + default: + return false + } +} + +func validateExchangeStatuses(status string, response json.RawMessage) error { + var payload struct { + Data struct { + Statuses []json.RawMessage `json:"statuses"` + } `json:"data"` + } + if err := json.Unmarshal(response, &payload); err != nil { + return nil + } + if len(payload.Data.Statuses) == 0 { + return nil + } + + var errs []string + for _, entryRaw := range payload.Data.Statuses { + var asString string + if err := json.Unmarshal(entryRaw, &asString); err == nil { + asString = strings.TrimSpace(asString) + if isBenignExchangeStatus(asString) { + continue + } + errs = append(errs, asString) + continue + } + + var asObject map[string]json.RawMessage + if err := json.Unmarshal(entryRaw, &asObject); err != nil { + continue + } + + rawErr, ok := asObject["error"] + if !ok { + continue + } + + var msg string + if err := json.Unmarshal(rawErr, &msg); err == nil && strings.TrimSpace(msg) != "" { + if isBenignExchangeStatus(msg) { + continue + } + errs = append(errs, msg) + continue + } + + rawErrText := strings.TrimSpace(string(rawErr)) + if isBenignExchangeStatus(rawErrText) { + continue + } + errs = append(errs, rawErrText) + } + + if len(errs) == 0 { + return nil + } + + return output.NewCLIError(output.ErrAPI, "exchange action returned error statuses"). + WithDetails("path", "/exchange"). + WithDetails("exchange_status", status). + WithDetails("exchange_errors", errs) +} diff --git a/pkg/client/weight.go b/pkg/client/weight.go index 4c4377b..014bc91 100644 --- a/pkg/client/weight.go +++ b/pkg/client/weight.go @@ -31,11 +31,7 @@ type WeightTracker struct { // NewWeightTracker creates a WeightTracker with default limit (1200) and window (1 minute). func NewWeightTracker() *WeightTracker { - return &WeightTracker{ - limit: defaultWeightLimit, - window: defaultWindow, - nowFunc: time.Now, - } + return &WeightTracker{limit: defaultWeightLimit, window: defaultWindow, nowFunc: time.Now} } // Record adds a weight entry and prunes expired entries. @@ -83,21 +79,14 @@ func (wt *WeightTracker) WarningJSON() json.RawMessage { return nil } - var oldest time.Time + oldest := now if len(wt.entries) > 0 { oldest = wt.entries[0].at - } else { - oldest = now } remaining := wt.window - now.Sub(oldest) remaining = max(remaining, 0) - msg := map[string]any{ - "warning": "rate_limit_approaching", - "current": total, - "limit": wt.limit, - "window_remaining": fmt.Sprintf("%.0fs", remaining.Seconds()), - } + msg := map[string]any{"warning": "rate_limit_approaching", "current": total, "limit": wt.limit, "window_remaining": fmt.Sprintf("%.0fs", remaining.Seconds())} data, _ := json.Marshal(msg) //nolint:errcheck // msg is a fixed-shape map, cannot fail return data } diff --git a/pkg/exchange/builder.go b/pkg/exchange/builder.go index c1ec3ca..7dc185f 100644 --- a/pkg/exchange/builder.go +++ b/pkg/exchange/builder.go @@ -55,125 +55,65 @@ func BuildOrderAction(orders []OrderParams, triggers []*TriggerParams, builder * if hasTrigger { grouping = "normalTpsl" } - - return &OrderAction{ - Type: "order", - Orders: wires, - Grouping: grouping, - Builder: builder, - } + return &OrderAction{Type: "order", Orders: wires, Grouping: grouping, Builder: builder} } // BuildCancelAction constructs a CancelAction from cancel wires. func BuildCancelAction(cancels []CancelWire) *CancelAction { - return &CancelAction{ - Type: "cancel", - Cancels: cancels, - } + return &CancelAction{Type: "cancel", Cancels: cancels} } // BuildCancelByCloidAction constructs a CancelByCloidAction from cancel-by-cloid wires. func BuildCancelByCloidAction(cancels []CancelByCloidWire) *CancelByCloidAction { - return &CancelByCloidAction{ - Type: "cancelByCloid", - Cancels: cancels, - } + return &CancelByCloidAction{Type: "cancelByCloid", Cancels: cancels} } // BuildUpdateLeverageAction constructs an UpdateLeverageAction. func BuildUpdateLeverageAction(assetID int, isCross bool, leverage int) *UpdateLeverageAction { - return &UpdateLeverageAction{ - Type: "updateLeverage", - Asset: assetID, - IsCross: isCross, - Leverage: leverage, - } + return &UpdateLeverageAction{Type: "updateLeverage", Asset: assetID, IsCross: isCross, Leverage: leverage} } // BuildUpdateIsolatedMarginAction constructs an UpdateIsolatedMarginAction. func BuildUpdateIsolatedMarginAction(assetID int, isBuy bool, ntli int64) *UpdateIsolatedMarginAction { - return &UpdateIsolatedMarginAction{ - Type: "updateIsolatedMargin", - Asset: assetID, - IsBuy: isBuy, - Ntli: ntli, - } + return &UpdateIsolatedMarginAction{Type: "updateIsolatedMargin", Asset: assetID, IsBuy: isBuy, Ntli: ntli} } // BuildModifyAction constructs a ModifyAction for a single order modification. func BuildModifyAction(oid uint64, order OrderWire) *ModifyAction { - return &ModifyAction{ - Type: "modify", - Oid: oid, - Order: order, - } + return &ModifyAction{Type: "modify", Oid: oid, Order: order} } // BuildScheduleCancelAction constructs a ScheduleCancelAction (dead man's switch). func BuildScheduleCancelAction(cancelTime *int64) *ScheduleCancelAction { - return &ScheduleCancelAction{ - Type: "scheduleCancel", - Time: cancelTime, - } + return &ScheduleCancelAction{Type: "scheduleCancel", Time: cancelTime} } // BuildUSDClassTransferAction constructs a usdClassTransfer user-signed action. func BuildUSDClassTransferAction(amount string, toPerp bool, nonce int64) *USDClassTransferAction { - return &USDClassTransferAction{ - Type: "usdClassTransfer", - Amount: amount, - ToPerp: toPerp, - Nonce: nonce, - } + return &USDClassTransferAction{Type: "usdClassTransfer", Amount: amount, ToPerp: toPerp, Nonce: nonce} } // BuildWithdraw3Action constructs a withdraw3 user-signed action. func BuildWithdraw3Action(destination, amount string, time int64) *Withdraw3Action { - return &Withdraw3Action{ - Type: "withdraw3", - Destination: destination, - Amount: amount, - Time: time, - } + return &Withdraw3Action{Type: "withdraw3", Destination: destination, Amount: amount, Time: time} } // BuildClassTransferAction constructs a classTransfer user-signed action. func BuildClassTransferAction(amount string, toPerp bool, nonce int64) *ClassTransferAction { - return &ClassTransferAction{ - Type: "classTransfer", - Amount: amount, - ToPerp: toPerp, - Nonce: nonce, - } + return &ClassTransferAction{Type: "classTransfer", Amount: amount, ToPerp: toPerp, Nonce: nonce} } // BuildSpotSendAction constructs a spotSend user-signed action. func BuildSpotSendAction(destination, token, amount string, time int64) *SpotSendAction { - return &SpotSendAction{ - Type: "spotSend", - Destination: destination, - Token: token, - Amount: amount, - Time: time, - } + return &SpotSendAction{Type: "spotSend", Destination: destination, Token: token, Amount: amount, Time: time} } // BuildApproveAgentAction constructs an approveAgent user-signed action. func BuildApproveAgentAction(agentAddress, agentName string, nonce int64) *ApproveAgentAction { - return &ApproveAgentAction{ - Type: "approveAgent", - AgentAddress: agentAddress, - AgentName: agentName, - Nonce: nonce, - } + return &ApproveAgentAction{Type: "approveAgent", AgentAddress: agentAddress, AgentName: agentName, Nonce: nonce} } // BuildUserSetAbstractionAction constructs a userSetAbstraction user-signed action. func BuildUserSetAbstractionAction(user, abstraction string, nonce int64) *UserSetAbstractionAction { - return &UserSetAbstractionAction{ - Type: "userSetAbstraction", - User: user, - Abstraction: abstraction, - Nonce: nonce, - } + return &UserSetAbstractionAction{Type: "userSetAbstraction", User: user, Abstraction: abstraction, Nonce: nonce} } diff --git a/pkg/exchange/executor.go b/pkg/exchange/executor.go index 914535c..9e0c29e 100644 --- a/pkg/exchange/executor.go +++ b/pkg/exchange/executor.go @@ -1,33 +1,17 @@ package exchange import ( - "context" "encoding/hex" "encoding/json" - "errors" - "strconv" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/signer/core/apitypes" - "github.com/shopspring/decimal" "github.com/timbrinded/hlgo/pkg/client" - "github.com/timbrinded/hlgo/pkg/info" - "github.com/timbrinded/hlgo/pkg/output" "github.com/timbrinded/hlgo/pkg/resolver" "github.com/timbrinded/hlgo/pkg/signer" - "github.com/timbrinded/hlgo/pkg/wire" ) // sigToWire converts a signer.Signature to the structured wire format expected by the exchange API. func sigToWire(sig *signer.Signature) client.SignatureWire { - return client.SignatureWire{ - R: "0x" + hex.EncodeToString(sig.R[:]), - S: "0x" + hex.EncodeToString(sig.S[:]), - V: int(sig.V), - } + return client.SignatureWire{R: "0x" + hex.EncodeToString(sig.R[:]), S: "0x" + hex.EncodeToString(sig.S[:]), V: int(sig.V)} } // Executor orchestrates the resolve → validate → sign → send pipeline for exchange actions. @@ -40,121 +24,7 @@ type Executor struct { // NewExecutor creates an Executor with the given dependencies. func NewExecutor(s signer.Signer, c *client.Client, r resolver.Resolver, mainnet bool) *Executor { - return &Executor{ - signer: s, - client: c, - resolver: r, - mainnet: mainnet, - } -} - -// PlaceOrderInput bundles the raw user-provided parameters for placing an order. -type PlaceOrderInput struct { - Coin string - Side string // "buy" or "sell" - Price decimal.Decimal - Size decimal.Decimal - Tif string // "Gtc", "Ioc", "Alo" - ReduceOnly bool - Cloid *string - TpTrigger *string // take-profit trigger price - SlTrigger *string // stop-loss trigger price - Builder *BuilderInfo - // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. - ExpiresAfter *int64 - DryRun bool -} - -// UpdateLeverageInput holds parameters for updating leverage. -type UpdateLeverageInput struct { - Coin string - IsCross bool - Leverage int - DryRun bool -} - -// UpdateIsolatedMarginInput holds parameters for adjusting isolated margin. -type UpdateIsolatedMarginInput struct { - Coin string - IsBuy bool - Amount decimal.Decimal - DryRun bool -} - -// ModifyOrderInput holds parameters for modifying an existing order. -type ModifyOrderInput struct { - Coin string - Oid uint64 - Side string - Price decimal.Decimal - Size decimal.Decimal - Tif string // "Gtc", "Ioc", "Alo" - ReduceOnly bool - Cloid *string - ExpiresAfter *int64 - DryRun bool -} - -// ScheduleCancelInput holds parameters for the dead man's switch. -type ScheduleCancelInput struct { - Time *int64 - DryRun bool -} - -// PlaceMarketOrderInput bundles parameters for placing a market order. -// Market orders are implemented as aggressive IOC limit orders. -type PlaceMarketOrderInput struct { - Coin string - Side string // "buy" or "sell" - Size decimal.Decimal - SlippagePercent decimal.Decimal - Builder *BuilderInfo - // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. - ExpiresAfter *int64 - DryRun bool -} - -// USDClassTransferInput holds parameters for usdClassTransfer account actions. -type USDClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - DryRun bool -} - -// Withdraw3Input holds parameters for withdraw3 account actions. -type Withdraw3Input struct { - Destination string - Amount decimal.Decimal - DryRun bool -} - -// ClassTransferInput holds parameters for classTransfer account actions. -type ClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - DryRun bool -} - -// SpotSendInput holds parameters for spotSend account actions. -type SpotSendInput struct { - Destination string - Token string - Amount decimal.Decimal - DryRun bool -} - -// ApproveAgentInput holds parameters for approveAgent account actions. -type ApproveAgentInput struct { - AgentAddress string - AgentName string - DryRun bool -} - -// UserSetAbstractionInput holds parameters for userSetAbstraction account actions. -type UserSetAbstractionInput struct { - User string - Abstraction string - DryRun bool + return &Executor{signer: s, client: c, resolver: r, mainnet: mainnet} } // PlaceOrderResult holds the result of a place order operation. @@ -183,731 +53,6 @@ type ResolvedOrder struct { IsSpot bool `json:"is_spot"` } -const userSignatureChainID = "0x66eee" - -var supportedUserAbstractions = map[string]struct{}{ - "unifiedAccount": {}, - "portfolioMargin": {}, - "disabled": {}, -} - -var ( - usdClassTransferSignTypes = []apitypes.Type{ - {Name: "hyperliquidChain", Type: "string"}, - {Name: "amount", Type: "string"}, - {Name: "toPerp", Type: "bool"}, - {Name: "nonce", Type: "uint64"}, - } - withdrawSignTypes = []apitypes.Type{ - {Name: "hyperliquidChain", Type: "string"}, - {Name: "destination", Type: "string"}, - {Name: "amount", Type: "string"}, - {Name: "time", Type: "uint64"}, - } - spotSendSignTypes = []apitypes.Type{ - {Name: "hyperliquidChain", Type: "string"}, - {Name: "destination", Type: "string"}, - {Name: "token", Type: "string"}, - {Name: "amount", Type: "string"}, - {Name: "time", Type: "uint64"}, - } - approveAgentSignTypes = []apitypes.Type{ - {Name: "hyperliquidChain", Type: "string"}, - {Name: "agentAddress", Type: "address"}, - {Name: "agentName", Type: "string"}, - {Name: "nonce", Type: "uint64"}, - } - userSetAbstractionSignTypes = []apitypes.Type{ - {Name: "hyperliquidChain", Type: "string"}, - {Name: "user", Type: "address"}, - {Name: "abstraction", Type: "string"}, - {Name: "nonce", Type: "uint64"}, - } -) - -// PlaceMarketOrder executes an IOC convenience order at a slippage-adjusted price. -func (e *Executor) PlaceMarketOrder(ctx context.Context, input PlaceMarketOrderInput) (*PlaceOrderResult, error) { - side := strings.ToLower(strings.TrimSpace(input.Side)) - if side != "buy" && side != "sell" { - return nil, output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). - WithDetails("value", input.Side) - } - - if input.SlippagePercent.IsNegative() { - return nil, output.NewCLIError(output.ErrValidation, "slippage must be non-negative"). - WithDetails("value", input.SlippagePercent.String()) - } - if input.SlippagePercent.GreaterThanOrEqual(decimal.NewFromInt(100)) { - return nil, output.NewCLIError(output.ErrValidation, "slippage must be less than 100 percent"). - WithDetails("value", input.SlippagePercent.String()) - } - - assetInfo, err := e.resolver.ResolveAsset(ctx, input.Coin) - if err != nil { - return nil, err - } - - canonicalCoin := assetInfo.CanonicalCoin - if canonicalCoin == "" { - canonicalCoin = assetInfo.Coin - } - if canonicalCoin == "" { - canonicalCoin = input.Coin - } - - midsReq := map[string]string{"type": "allMids"} - if dex := marketCoinDex(canonicalCoin); dex != "" { - midsReq["dex"] = dex - } - - midsRaw, err := e.client.PostInfo(ctx, midsReq) - if err != nil { - return nil, err - } - - mids, err := info.ParseMidsResult(midsRaw) - if err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to parse mids response"). - WithDetails("cause", err.Error()) - } - - midStr, ok := mids[canonicalCoin] - if !ok { - return nil, output.NewCLIError(output.ErrValidation, "no mid price found for coin: "+canonicalCoin). - WithDetails("coin", input.Coin). - WithDetails("canonical_coin", canonicalCoin) - } - - mid, err := decimal.NewFromString(midStr) - if err != nil { - return nil, output.NewCLIError(output.ErrAPI, "invalid mid price from API"). - WithDetails("coin", canonicalCoin). - WithDetails("value", midStr) - } - - slippage := input.SlippagePercent.Div(decimal.NewFromInt(100)) - price := mid.Mul(decimal.NewFromInt(1).Sub(slippage)) - if side == "buy" { - price = mid.Mul(decimal.NewFromInt(1).Add(slippage)) - } - price = wire.NearestValidPrice(price, assetInfo.SzDecimals, assetInfo.IsSpot) - - return e.PlaceOrder(ctx, PlaceOrderInput{ - Coin: input.Coin, - Side: side, - Price: price, - Size: input.Size, - Tif: "Ioc", - Builder: input.Builder, - ExpiresAfter: input.ExpiresAfter, - DryRun: input.DryRun, - }) -} - -func marketCoinDex(coin string) string { - idx := strings.Index(coin, ":") - if idx <= 0 { - return "" - } - return strings.ToLower(strings.TrimSpace(coin[:idx])) -} - -func wrapTriggerPriceError(flag string, err error) error { - var cliErr *output.CLIError - if errors.As(err, &cliErr) { - wrapped := output.NewCLIError(cliErr.Code, flag+" trigger price: "+cliErr.Message). - WithDetails("flag", flag) - for k, v := range cliErr.Details { - wrapped = wrapped.WithDetails(k, v) - } - return wrapped - } - - return output.NewCLIError(output.ErrValidation, flag+" trigger price validation failed"). - WithDetails("flag", flag). - WithDetails("cause", err.Error()) -} - -// PlaceOrder executes the full order placement pipeline. -func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*PlaceOrderResult, error) { - // 1. Resolve coin → asset info. - info, err := e.resolver.ResolveAsset(ctx, input.Coin) - if err != nil { - return nil, err - } - - // 2. Validate and format price. - priceStr, err := wire.PriceToWire(input.Price, info.SzDecimals, info.IsSpot) - if err != nil { - return nil, err - } - - // 3. Format size. - sizeStr, err := wire.SizeToWire(input.Size, info.SzDecimals) - if err != nil { - return nil, err - } - - isBuy := input.Side == "buy" - - // 4. Build the main limit order (no trigger params — those are separate wires). - action := BuildOrderAction([]OrderParams{{ - AssetID: info.AssetID, - IsBuy: isBuy, - Price: priceStr, - Size: sizeStr, - ReduceOnly: input.ReduceOnly, - Tif: input.Tif, - Cloid: input.Cloid, - }}, nil, input.Builder) - - // 5. Append TP/SL trigger wires if present. - // Each trigger is a separate reduce-only order on the opposite side, using the same - // size as the main order. The trigger price goes in TriggerPx (IsMarket=true means - // the trigger fires a market order at that level), while Price/Size are wire-required - // fields that match the main order. - type triggerDef struct { - px string - tpsl string - } - var triggers []triggerDef - if input.TpTrigger != nil { - tpPrice, err := decimal.NewFromString(*input.TpTrigger) - if err != nil { - return nil, output.NewCLIError(output.ErrValidation, "invalid take-profit trigger price"). - WithDetails("value", *input.TpTrigger) - } - tpWire, err := wire.PriceToWire(tpPrice, info.SzDecimals, info.IsSpot) - if err != nil { - return nil, wrapTriggerPriceError("--tp", err) - } - triggers = append(triggers, triggerDef{px: tpWire, tpsl: "tp"}) - } - if input.SlTrigger != nil { - slPrice, err := decimal.NewFromString(*input.SlTrigger) - if err != nil { - return nil, output.NewCLIError(output.ErrValidation, "invalid stop-loss trigger price"). - WithDetails("value", *input.SlTrigger) - } - slWire, err := wire.PriceToWire(slPrice, info.SzDecimals, info.IsSpot) - if err != nil { - return nil, wrapTriggerPriceError("--sl", err) - } - triggers = append(triggers, triggerDef{px: slWire, tpsl: "sl"}) - } - for _, trig := range triggers { - trigOrder := OrderParams{ - AssetID: info.AssetID, - IsBuy: !isBuy, - Price: priceStr, - Size: sizeStr, - ReduceOnly: true, - } - trigAction := BuildOrderAction([]OrderParams{trigOrder}, []*TriggerParams{{ - TriggerPx: trig.px, - Tpsl: trig.tpsl, - }}, nil) - action.Orders = append(action.Orders, trigAction.Orders...) - action.Grouping = "normalTpsl" - } - - resolved := &ResolvedOrder{ - Coin: info.Coin, - AssetID: info.AssetID, - Side: input.Side, - Price: priceStr, - Size: sizeStr, - Tif: input.Tif, - ReduceOnly: input.ReduceOnly, - IsSpot: info.IsSpot, - } - - // 6. Dry-run: return action without signing/sending. - if input.DryRun { - return &PlaceOrderResult{ - Action: action, - Resolved: resolved, - }, nil - } - - // 7. Generate nonce and sign. - nonce := time.Now().UnixMilli() - - // On-behalf trading uses the agent signer identity; it is not encoded as - // vaultAddress in the L1 action hash/payload. - sig, err := e.signer.SignL1Action(action, nonce, nil, input.ExpiresAfter, e.mainnet) - if err != nil { - return nil, err - } - - // 8. Send to exchange. - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) - if err != nil { - return nil, err - } - - return &PlaceOrderResult{ - Response: resp, - Action: action, - Resolved: resolved, - }, nil -} - -// CancelOrders cancels orders by OID. -func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { - action := BuildCancelAction(cancels) - - if dryRun { - return json.Marshal(action) - } - - nonce := time.Now().UnixMilli() - - sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) -} - -// CancelByCloid cancels orders by client order ID. -func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { - action := BuildCancelByCloidAction(cancels) - - if dryRun { - return json.Marshal(action) - } - - nonce := time.Now().UnixMilli() - - sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) -} - -// UpdateLeverage sets leverage and margin mode for a coin. -func (e *Executor) UpdateLeverage(ctx context.Context, input UpdateLeverageInput) (json.RawMessage, error) { - if input.Leverage < 1 { - return nil, output.NewCLIError(output.ErrValidation, "leverage must be at least 1"). - WithDetails("value", input.Leverage) - } - - info, err := e.resolver.ResolveAsset(ctx, input.Coin) - if err != nil { - return nil, err - } - - action := BuildUpdateLeverageAction(info.AssetID, input.IsCross, input.Leverage) - - if input.DryRun { - return json.Marshal(action) - } - - nonce := time.Now().UnixMilli() - - // On-behalf trading context is handled by agent authorization, not vaultAddress. - sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) -} - -// UpdateIsolatedMargin adjusts isolated margin for a position. -func (e *Executor) UpdateIsolatedMargin(ctx context.Context, input UpdateIsolatedMarginInput) (json.RawMessage, error) { - info, err := e.resolver.ResolveAsset(ctx, input.Coin) - if err != nil { - return nil, err - } - - // Convert decimal amount to integer ntli (micro-units: amount * 1_000_000). - ntli := input.Amount.Mul(decimal.NewFromInt(1_000_000)) - if !ntli.IsInteger() { - return nil, output.NewCLIError(output.ErrValidation, "amount precision exceeds 6 decimal places"). - WithDetails("value", input.Amount.String()) - } - ntliInt, err := strconv.ParseInt(ntli.String(), 10, 64) - if err != nil { - return nil, output.NewCLIError(output.ErrValidation, "amount is out of range"). - WithDetails("value", input.Amount.String()) - } - - action := BuildUpdateIsolatedMarginAction(info.AssetID, input.IsBuy, ntliInt) - - if input.DryRun { - return json.Marshal(action) - } - - nonce := time.Now().UnixMilli() - - // On-behalf trading context is handled by agent authorization, not vaultAddress. - sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) -} - -// ModifyOrder modifies an existing order. -func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*ModifyOrderResult, error) { - info, err := e.resolver.ResolveAsset(ctx, input.Coin) - if err != nil { - return nil, err - } - - priceStr, err := wire.PriceToWire(input.Price, info.SzDecimals, info.IsSpot) - if err != nil { - return nil, err - } - - sizeStr, err := wire.SizeToWire(input.Size, info.SzDecimals) - if err != nil { - return nil, err - } - - isBuy := input.Side == "buy" - - orderWire := OrderWire{ - A: info.AssetID, - B: isBuy, - P: priceStr, - S: sizeStr, - R: input.ReduceOnly, - T: OrderTypeWire{ - Limit: &LimitTif{Tif: input.Tif}, - }, - C: input.Cloid, - } - - action := BuildModifyAction(input.Oid, orderWire) - - resolved := &ResolvedOrder{ - Coin: info.Coin, - AssetID: info.AssetID, - Side: input.Side, - Price: priceStr, - Size: sizeStr, - Tif: input.Tif, - ReduceOnly: input.ReduceOnly, - IsSpot: info.IsSpot, - } - - if input.DryRun { - return &ModifyOrderResult{ - Action: action, - Resolved: resolved, - }, nil - } - - nonce := time.Now().UnixMilli() - - // On-behalf trading context is handled by agent authorization, not vaultAddress. - sig, err := e.signer.SignL1Action(action, nonce, nil, input.ExpiresAfter, e.mainnet) - if err != nil { - return nil, err - } - - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) - if err != nil { - return nil, err - } - - return &ModifyOrderResult{ - Response: resp, - Resolved: resolved, - }, nil -} - -// PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. -func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, expiresAfter *int64) (json.RawMessage, error) { - nonce := time.Now().UnixMilli() - - sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) -} - -// ScheduleCancel sets or clears the dead man's switch for order cancellation. -func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput) (json.RawMessage, error) { - action := BuildScheduleCancelAction(input.Time) - - if input.DryRun { - return json.Marshal(action) - } - - nonce := time.Now().UnixMilli() - - sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) -} - -// USDClassTransfer executes a usdClassTransfer user-signed action. -func (e *Executor) USDClassTransfer(ctx context.Context, input USDClassTransferInput) (json.RawMessage, error) { - if !input.Amount.IsPositive() { - return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). - WithDetails("value", input.Amount.String()) - } - - nonce := time.Now().UnixMilli() - action := BuildUSDClassTransferAction(input.Amount.String(), input.ToPerp, nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:UsdClassTransfer", - usdClassTransferSignTypes, - input.DryRun, - ) -} - -// Withdraw3 executes a withdraw3 user-signed action. -func (e *Executor) Withdraw3(ctx context.Context, input Withdraw3Input) (json.RawMessage, error) { - if !common.IsHexAddress(input.Destination) { - return nil, output.NewCLIError(output.ErrValidation, "invalid destination address"). - WithDetails("destination", input.Destination) - } - if !input.Amount.IsPositive() { - return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). - WithDetails("value", input.Amount.String()) - } - - nonce := time.Now().UnixMilli() - action := BuildWithdraw3Action(strings.ToLower(input.Destination), input.Amount.String(), nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:Withdraw", - withdrawSignTypes, - input.DryRun, - ) -} - -// ClassTransfer executes a classTransfer user-signed action. -func (e *Executor) ClassTransfer(ctx context.Context, input ClassTransferInput) (json.RawMessage, error) { - if !input.Amount.IsPositive() { - return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). - WithDetails("value", input.Amount.String()) - } - - nonce := time.Now().UnixMilli() - action := BuildUSDClassTransferAction(input.Amount.String(), input.ToPerp, nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:UsdClassTransfer", - usdClassTransferSignTypes, - input.DryRun, - ) -} - -// SpotSend executes a spotSend user-signed action. -func (e *Executor) SpotSend(ctx context.Context, input SpotSendInput) (json.RawMessage, error) { - if !common.IsHexAddress(input.Destination) { - return nil, output.NewCLIError(output.ErrValidation, "invalid destination address"). - WithDetails("destination", input.Destination) - } - if strings.TrimSpace(input.Token) == "" { - return nil, output.NewCLIError(output.ErrValidation, "token is required") - } - if !input.Amount.IsPositive() { - return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). - WithDetails("value", input.Amount.String()) - } - - nonce := time.Now().UnixMilli() - action := BuildSpotSendAction(strings.ToLower(input.Destination), input.Token, input.Amount.String(), nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:SpotSend", - spotSendSignTypes, - input.DryRun, - ) -} - -// ApproveAgent executes an approveAgent user-signed action. -func (e *Executor) ApproveAgent(ctx context.Context, input ApproveAgentInput) (json.RawMessage, error) { - if !common.IsHexAddress(input.AgentAddress) { - return nil, output.NewCLIError(output.ErrValidation, "invalid agent address"). - WithDetails("agent", input.AgentAddress) - } - - nonce := time.Now().UnixMilli() - action := BuildApproveAgentAction(strings.ToLower(input.AgentAddress), input.AgentName, nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:ApproveAgent", - approveAgentSignTypes, - input.DryRun, - ) -} - -// UserSetAbstraction executes a userSetAbstraction user-signed action. -func (e *Executor) UserSetAbstraction(ctx context.Context, input UserSetAbstractionInput) (json.RawMessage, error) { - if !common.IsHexAddress(input.User) { - return nil, output.NewCLIError(output.ErrValidation, "invalid user address"). - WithDetails("user", input.User) - } - abstraction := strings.TrimSpace(input.Abstraction) - if abstraction == "" { - return nil, output.NewCLIError(output.ErrValidation, "abstraction is required") - } - if _, ok := supportedUserAbstractions[abstraction]; !ok { - return nil, output.NewCLIError(output.ErrValidation, "unsupported abstraction value"). - WithDetails("value", abstraction). - WithDetails("allowed", []string{"unifiedAccount", "portfolioMargin", "disabled"}) - } - - nonce := time.Now().UnixMilli() - action := BuildUserSetAbstractionAction(strings.ToLower(input.User), abstraction, nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:UserSetAbstraction", - userSetAbstractionSignTypes, - input.DryRun, - ) -} - -func (e *Executor) executeUserAction( - ctx context.Context, - action any, - nonce int64, - typeName string, - typeFields []apitypes.Type, - dryRun bool, -) (json.RawMessage, error) { - actionMap, err := userActionMap(action) - if err != nil { - return nil, output.NewCLIError(output.ErrAPI, "failed to build action payload"). - WithDetails("cause", err.Error()) - } - - // Keep chain metadata centralized in signer behavior; this payload metadata is - // only to satisfy exchange request shape and does not participate in typed hashing. - actionMap["signatureChainId"] = userSignatureChainID - actionMap["hyperliquidChain"] = userChain(e.mainnet) - - if dryRun { - return json.Marshal(actionMap) - } - - // Sign only fields declared in the typed schema; payload-only metadata - // (e.g. signatureChainId) is excluded from the typed message. - signMessage := make(map[string]any, len(typeFields)) - for _, field := range typeFields { - if field.Name == "hyperliquidChain" { - continue - } - - value, ok := actionMap[field.Name] - if !ok { - // Revoke flow signs agentName="" but omits the field from the wire payload. - if typeName == "HyperliquidTransaction:ApproveAgent" && field.Name == "agentName" { - value = "" - } else { - continue - } - } - - if strings.HasPrefix(field.Type, "uint") { - switch v := value.(type) { - case int64: - value = strconv.FormatInt(v, 10) - case int: - value = strconv.Itoa(v) - case uint64: - value = strconv.FormatUint(v, 10) - case uint: - value = strconv.FormatUint(uint64(v), 10) - } - } - signMessage[field.Name] = value - } - - sig, err := e.signer.SignUserAction(typeName, typeFields, signMessage, e.mainnet) - if err != nil { - return nil, err - } - - return e.client.PostExchange(ctx, actionMap, nonce, sigToWire(sig), "", nil) -} - -func userActionMap(action any) (map[string]any, error) { - switch a := action.(type) { - case *USDClassTransferAction: - return map[string]any{ - "type": a.Type, - "amount": a.Amount, - "toPerp": a.ToPerp, - "nonce": a.Nonce, - }, nil - case *Withdraw3Action: - return map[string]any{ - "type": a.Type, - "destination": a.Destination, - "amount": a.Amount, - "time": a.Time, - }, nil - case *ClassTransferAction: - return map[string]any{ - "type": a.Type, - "amount": a.Amount, - "toPerp": a.ToPerp, - "nonce": a.Nonce, - }, nil - case *SpotSendAction: - return map[string]any{ - "type": a.Type, - "destination": a.Destination, - "token": a.Token, - "amount": a.Amount, - "time": a.Time, - }, nil - case *ApproveAgentAction: - m := map[string]any{ - "type": a.Type, - "agentAddress": a.AgentAddress, - "nonce": a.Nonce, - } - if strings.TrimSpace(a.AgentName) != "" { - m["agentName"] = a.AgentName - } - return m, nil - case *UserSetAbstractionAction: - return map[string]any{ - "type": a.Type, - "user": a.User, - "abstraction": a.Abstraction, - "nonce": a.Nonce, - }, nil - default: - return nil, output.NewCLIError(output.ErrAPI, "unsupported user action type") - } -} - -func userChain(mainnet bool) string { - if mainnet { - return "Mainnet" - } - return "Testnet" +func newResolvedOrder(coin string, assetID int, side, price, size, tif string, reduceOnly, isSpot bool) *ResolvedOrder { + return &ResolvedOrder{Coin: coin, AssetID: assetID, Side: side, Price: price, Size: size, Tif: tif, ReduceOnly: reduceOnly, IsSpot: isSpot} } diff --git a/pkg/exchange/executor_management.go b/pkg/exchange/executor_management.go new file mode 100644 index 0000000..55ea66c --- /dev/null +++ b/pkg/exchange/executor_management.go @@ -0,0 +1,192 @@ +package exchange + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/shopspring/decimal" + + "github.com/timbrinded/hlgo/pkg/output" + "github.com/timbrinded/hlgo/pkg/wire" +) + +// UpdateLeverageInput holds parameters for updating leverage. +type UpdateLeverageInput struct { + Coin string + IsCross bool + Leverage int + DryRun bool +} + +// UpdateIsolatedMarginInput holds parameters for adjusting isolated margin. +type UpdateIsolatedMarginInput struct { + Coin string + IsBuy bool + Amount decimal.Decimal + DryRun bool +} + +// ModifyOrderInput holds parameters for modifying an existing order. +type ModifyOrderInput struct { + Coin string + Oid uint64 + Side string + Price decimal.Decimal + Size decimal.Decimal + Tif string // "Gtc", "Ioc", "Alo" + ReduceOnly bool + Cloid *string + ExpiresAfter *int64 + DryRun bool +} + +// ScheduleCancelInput holds parameters for the dead man's switch. +type ScheduleCancelInput struct { + Time *int64 + DryRun bool +} + +// CancelOrders cancels orders by OID. +func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { + action := BuildCancelAction(cancels) + + if dryRun { + return json.Marshal(action) + } + + return e.executeL1Action(ctx, action, expiresAfter) +} + +// CancelByCloid cancels orders by client order ID. +func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { + action := BuildCancelByCloidAction(cancels) + + if dryRun { + return json.Marshal(action) + } + + return e.executeL1Action(ctx, action, expiresAfter) +} + +// UpdateLeverage sets leverage and margin mode for a coin. +func (e *Executor) UpdateLeverage(ctx context.Context, input UpdateLeverageInput) (json.RawMessage, error) { + if input.Leverage < 1 { + return nil, output.NewCLIError(output.ErrValidation, "leverage must be at least 1"). + WithDetails("value", input.Leverage) + } + + info, err := e.resolver.ResolveAsset(ctx, input.Coin) + if err != nil { + return nil, err + } + + action := BuildUpdateLeverageAction(info.AssetID, input.IsCross, input.Leverage) + + if input.DryRun { + return json.Marshal(action) + } + + return e.executeL1Action(ctx, action, nil) +} + +// UpdateIsolatedMargin adjusts isolated margin for a position. +func (e *Executor) UpdateIsolatedMargin(ctx context.Context, input UpdateIsolatedMarginInput) (json.RawMessage, error) { + info, err := e.resolver.ResolveAsset(ctx, input.Coin) + if err != nil { + return nil, err + } + + // Convert decimal amount to integer ntli (micro-units: amount * 1_000_000). + ntli := input.Amount.Mul(decimal.NewFromInt(1_000_000)) + if !ntli.IsInteger() { + return nil, output.NewCLIError(output.ErrValidation, "amount precision exceeds 6 decimal places"). + WithDetails("value", input.Amount.String()) + } + ntliInt, err := strconv.ParseInt(ntli.String(), 10, 64) + if err != nil { + return nil, output.NewCLIError(output.ErrValidation, "amount is out of range"). + WithDetails("value", input.Amount.String()) + } + + action := BuildUpdateIsolatedMarginAction(info.AssetID, input.IsBuy, ntliInt) + + if input.DryRun { + return json.Marshal(action) + } + + return e.executeL1Action(ctx, action, nil) +} + +// ModifyOrder modifies an existing order. +func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*ModifyOrderResult, error) { + info, err := e.resolver.ResolveAsset(ctx, input.Coin) + if err != nil { + return nil, err + } + + priceStr, err := wire.PriceToWire(input.Price, info.SzDecimals, info.IsSpot) + if err != nil { + return nil, err + } + + sizeStr, err := wire.SizeToWire(input.Size, info.SzDecimals) + if err != nil { + return nil, err + } + + isBuy := input.Side == "buy" + + orderWire := OrderWire{ + A: info.AssetID, + B: isBuy, + P: priceStr, + S: sizeStr, + R: input.ReduceOnly, + T: OrderTypeWire{ + Limit: &LimitTif{Tif: input.Tif}, + }, + C: input.Cloid, + } + + action := BuildModifyAction(input.Oid, orderWire) + + resolved := newResolvedOrder(info.Coin, info.AssetID, input.Side, priceStr, sizeStr, input.Tif, input.ReduceOnly, info.IsSpot) + if input.DryRun { + return &ModifyOrderResult{Action: action, Resolved: resolved}, nil + } + + resp, err := e.executeL1Action(ctx, action, input.ExpiresAfter) + if err != nil { + return nil, err + } + return &ModifyOrderResult{Response: resp, Resolved: resolved}, nil +} + +// executeL1Action signs and posts a standard L1 action. +// On-behalf trading context is handled by agent authorization, not vaultAddress. +func (e *Executor) executeL1Action(ctx context.Context, action any, expiresAfter *int64) (json.RawMessage, error) { + nonce := time.Now().UnixMilli() + sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) + if err != nil { + return nil, err + } + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) +} + +// PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. +func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, expiresAfter *int64) (json.RawMessage, error) { + return e.executeL1Action(ctx, action, expiresAfter) +} + +// ScheduleCancel sets or clears the dead man's switch for order cancellation. +func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput) (json.RawMessage, error) { + action := BuildScheduleCancelAction(input.Time) + + if input.DryRun { + return json.Marshal(action) + } + + return e.executeL1Action(ctx, action, nil) +} diff --git a/pkg/exchange/executor_orders.go b/pkg/exchange/executor_orders.go new file mode 100644 index 0000000..86de585 --- /dev/null +++ b/pkg/exchange/executor_orders.go @@ -0,0 +1,224 @@ +package exchange + +import ( + "context" + "errors" + "strings" + + "github.com/shopspring/decimal" + + "github.com/timbrinded/hlgo/pkg/info" + "github.com/timbrinded/hlgo/pkg/output" + "github.com/timbrinded/hlgo/pkg/wire" +) + +// PlaceOrderInput bundles the raw user-provided parameters for placing an order. +type PlaceOrderInput struct { + Coin string + Side string // "buy" or "sell" + Price decimal.Decimal + Size decimal.Decimal + Tif string // "Gtc", "Ioc", "Alo" + ReduceOnly bool + Cloid *string + TpTrigger *string // take-profit trigger price + SlTrigger *string // stop-loss trigger price + Builder *BuilderInfo + // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. + ExpiresAfter *int64 + DryRun bool +} + +// PlaceMarketOrderInput bundles parameters for placing a market order. +// Market orders are implemented as aggressive IOC limit orders. +type PlaceMarketOrderInput struct { + Coin string + Side string // "buy" or "sell" + Size decimal.Decimal + SlippagePercent decimal.Decimal + Builder *BuilderInfo + // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. + ExpiresAfter *int64 + DryRun bool +} + +// PlaceMarketOrder executes an IOC convenience order at a slippage-adjusted price. +func (e *Executor) PlaceMarketOrder(ctx context.Context, input PlaceMarketOrderInput) (*PlaceOrderResult, error) { + side := strings.ToLower(strings.TrimSpace(input.Side)) + if side != "buy" && side != "sell" { + return nil, output.NewCLIError(output.ErrValidation, "side must be 'buy' or 'sell'"). + WithDetails("value", input.Side) + } + + if input.SlippagePercent.IsNegative() { + return nil, output.NewCLIError(output.ErrValidation, "slippage must be non-negative"). + WithDetails("value", input.SlippagePercent.String()) + } + if input.SlippagePercent.GreaterThanOrEqual(decimal.NewFromInt(100)) { + return nil, output.NewCLIError(output.ErrValidation, "slippage must be less than 100 percent"). + WithDetails("value", input.SlippagePercent.String()) + } + + assetInfo, err := e.resolver.ResolveAsset(ctx, input.Coin) + if err != nil { + return nil, err + } + + canonicalCoin := assetInfo.CanonicalCoin + if canonicalCoin == "" { + canonicalCoin = assetInfo.Coin + } + if canonicalCoin == "" { + canonicalCoin = input.Coin + } + + mid, err := e.fetchMarketMid(ctx, input.Coin, canonicalCoin) + if err != nil { + return nil, err + } + + slippage := input.SlippagePercent.Div(decimal.NewFromInt(100)) + price := mid.Mul(decimal.NewFromInt(1).Sub(slippage)) + if side == "buy" { + price = mid.Mul(decimal.NewFromInt(1).Add(slippage)) + } + price = wire.NearestValidPrice(price, assetInfo.SzDecimals, assetInfo.IsSpot) + + return e.PlaceOrder(ctx, PlaceOrderInput{ + Coin: input.Coin, + Side: side, + Price: price, + Size: input.Size, + Tif: "Ioc", + Builder: input.Builder, + ExpiresAfter: input.ExpiresAfter, + DryRun: input.DryRun, + }) +} + +func marketCoinDex(coin string) string { + idx := strings.Index(coin, ":") + if idx <= 0 { + return "" + } + return strings.ToLower(strings.TrimSpace(coin[:idx])) +} + +func (e *Executor) fetchMarketMid(ctx context.Context, inputCoin, canonicalCoin string) (decimal.Decimal, error) { + midsReq := map[string]string{"type": "allMids"} + if dex := marketCoinDex(canonicalCoin); dex != "" { + midsReq["dex"] = dex + } + + midsRaw, err := e.client.PostInfo(ctx, midsReq) + if err != nil { + return decimal.Zero, err + } + mids, err := info.ParseMidsResult(midsRaw) + if err != nil { + return decimal.Zero, output.NewCLIError(output.ErrAPI, "failed to parse mids response"). + WithDetails("cause", err.Error()) + } + + midStr, ok := mids[canonicalCoin] + if !ok { + return decimal.Zero, output.NewCLIError(output.ErrValidation, "no mid price found for coin: "+canonicalCoin). + WithDetails("coin", inputCoin). + WithDetails("canonical_coin", canonicalCoin) + } + + mid, err := decimal.NewFromString(midStr) + if err != nil { + return decimal.Zero, output.NewCLIError(output.ErrAPI, "invalid mid price from API"). + WithDetails("coin", canonicalCoin). + WithDetails("value", midStr) + } + return mid, nil +} + +func wrapTriggerPriceError(flag string, err error) error { + var cliErr *output.CLIError + if errors.As(err, &cliErr) { + wrapped := output.NewCLIError(cliErr.Code, flag+" trigger price: "+cliErr.Message). + WithDetails("flag", flag) + for k, v := range cliErr.Details { + wrapped = wrapped.WithDetails(k, v) + } + return wrapped + } + + return output.NewCLIError(output.ErrValidation, flag+" trigger price validation failed"). + WithDetails("flag", flag). + WithDetails("cause", err.Error()) +} + +func appendTriggerOrders(action *OrderAction, input PlaceOrderInput, assetID, szDecimals int, isSpot, isBuy bool, priceStr, sizeStr string) error { + type triggerDef struct { + flag string + value *string + parseMessage string + tpsl string + } + for _, trigger := range []triggerDef{{flag: "--tp", value: input.TpTrigger, parseMessage: "invalid take-profit trigger price", tpsl: "tp"}, {flag: "--sl", value: input.SlTrigger, parseMessage: "invalid stop-loss trigger price", tpsl: "sl"}} { + if trigger.value == nil { + continue + } + triggerPrice, err := decimal.NewFromString(*trigger.value) + if err != nil { + return output.NewCLIError(output.ErrValidation, trigger.parseMessage). + WithDetails("value", *trigger.value) + } + triggerPx, err := wire.PriceToWire(triggerPrice, szDecimals, isSpot) + if err != nil { + return wrapTriggerPriceError(trigger.flag, err) + } + action.Orders = append(action.Orders, BuildOrderAction([]OrderParams{{AssetID: assetID, IsBuy: !isBuy, Price: priceStr, Size: sizeStr, ReduceOnly: true}}, []*TriggerParams{{TriggerPx: triggerPx, Tpsl: trigger.tpsl}}, nil).Orders...) + action.Grouping = "normalTpsl" + } + return nil +} + +// PlaceOrder executes the full order placement pipeline. +func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*PlaceOrderResult, error) { + info, err := e.resolver.ResolveAsset(ctx, input.Coin) + if err != nil { + return nil, err + } + + priceStr, err := wire.PriceToWire(input.Price, info.SzDecimals, info.IsSpot) + if err != nil { + return nil, err + } + + sizeStr, err := wire.SizeToWire(input.Size, info.SzDecimals) + if err != nil { + return nil, err + } + + isBuy := input.Side == "buy" + + action := BuildOrderAction([]OrderParams{{ + AssetID: info.AssetID, + IsBuy: isBuy, + Price: priceStr, + Size: sizeStr, + ReduceOnly: input.ReduceOnly, + Tif: input.Tif, + Cloid: input.Cloid, + }}, nil, input.Builder) + + if err := appendTriggerOrders(action, input, info.AssetID, info.SzDecimals, info.IsSpot, isBuy, priceStr, sizeStr); err != nil { + return nil, err + } + + resolved := newResolvedOrder(info.Coin, info.AssetID, input.Side, priceStr, sizeStr, input.Tif, input.ReduceOnly, info.IsSpot) + if input.DryRun { + return &PlaceOrderResult{Action: action, Resolved: resolved}, nil + } + + resp, err := e.executeL1Action(ctx, action, input.ExpiresAfter) + if err != nil { + return nil, err + } + return &PlaceOrderResult{Response: resp, Action: action, Resolved: resolved}, nil +} diff --git a/pkg/exchange/executor_user.go b/pkg/exchange/executor_user.go new file mode 100644 index 0000000..e4fe68c --- /dev/null +++ b/pkg/exchange/executor_user.go @@ -0,0 +1,236 @@ +package exchange + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/shopspring/decimal" + + "github.com/timbrinded/hlgo/pkg/output" +) + +// USDClassTransferInput holds parameters for usdClassTransfer account actions. +type USDClassTransferInput struct { + Amount decimal.Decimal + ToPerp bool + DryRun bool +} + +// Withdraw3Input holds parameters for withdraw3 account actions. +type Withdraw3Input struct { + Destination string + Amount decimal.Decimal + DryRun bool +} + +// ClassTransferInput holds parameters for classTransfer account actions. +type ClassTransferInput struct { + Amount decimal.Decimal + ToPerp bool + DryRun bool +} + +// SpotSendInput holds parameters for spotSend account actions. +type SpotSendInput struct { + Destination string + Token string + Amount decimal.Decimal + DryRun bool +} + +// ApproveAgentInput holds parameters for approveAgent account actions. +type ApproveAgentInput struct { + AgentAddress string + AgentName string + DryRun bool +} + +// UserSetAbstractionInput holds parameters for userSetAbstraction account actions. +type UserSetAbstractionInput struct { + User string + Abstraction string + DryRun bool +} + +const userSignatureChainID = "0x66eee" + +func isSupportedUserAbstraction(abstraction string) bool { + switch abstraction { + case "unifiedAccount", "portfolioMargin", "disabled": + return true + default: + return false + } +} + +var ( + usdClassTransferSignTypes = []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "amount", Type: "string"}, + {Name: "toPerp", Type: "bool"}, + {Name: "nonce", Type: "uint64"}, + } + withdrawSignTypes = []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "destination", Type: "string"}, + {Name: "amount", Type: "string"}, + {Name: "time", Type: "uint64"}, + } + spotSendSignTypes = []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "destination", Type: "string"}, + {Name: "token", Type: "string"}, + {Name: "amount", Type: "string"}, + {Name: "time", Type: "uint64"}, + } + approveAgentSignTypes = []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "agentAddress", Type: "address"}, + {Name: "agentName", Type: "string"}, + {Name: "nonce", Type: "uint64"}, + } + userSetAbstractionSignTypes = []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "user", Type: "address"}, + {Name: "abstraction", Type: "string"}, + {Name: "nonce", Type: "uint64"}, + } +) + +// USDClassTransfer executes a usdClassTransfer user-signed action. +func (e *Executor) USDClassTransfer(ctx context.Context, input USDClassTransferInput) (json.RawMessage, error) { + if !input.Amount.IsPositive() { + return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). + WithDetails("value", input.Amount.String()) + } + + nonce := time.Now().UnixMilli() + action := BuildUSDClassTransferAction(input.Amount.String(), input.ToPerp, nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:UsdClassTransfer", + usdClassTransferSignTypes, + input.DryRun, + ) +} + +// Withdraw3 executes a withdraw3 user-signed action. +func (e *Executor) Withdraw3(ctx context.Context, input Withdraw3Input) (json.RawMessage, error) { + if !common.IsHexAddress(input.Destination) { + return nil, output.NewCLIError(output.ErrValidation, "invalid destination address"). + WithDetails("destination", input.Destination) + } + if !input.Amount.IsPositive() { + return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). + WithDetails("value", input.Amount.String()) + } + + nonce := time.Now().UnixMilli() + action := BuildWithdraw3Action(strings.ToLower(input.Destination), input.Amount.String(), nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:Withdraw", + withdrawSignTypes, + input.DryRun, + ) +} + +// ClassTransfer executes a classTransfer user-signed action. +func (e *Executor) ClassTransfer(ctx context.Context, input ClassTransferInput) (json.RawMessage, error) { + if !input.Amount.IsPositive() { + return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). + WithDetails("value", input.Amount.String()) + } + + nonce := time.Now().UnixMilli() + action := BuildUSDClassTransferAction(input.Amount.String(), input.ToPerp, nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:UsdClassTransfer", + usdClassTransferSignTypes, + input.DryRun, + ) +} + +// SpotSend executes a spotSend user-signed action. +func (e *Executor) SpotSend(ctx context.Context, input SpotSendInput) (json.RawMessage, error) { + if !common.IsHexAddress(input.Destination) { + return nil, output.NewCLIError(output.ErrValidation, "invalid destination address"). + WithDetails("destination", input.Destination) + } + if strings.TrimSpace(input.Token) == "" { + return nil, output.NewCLIError(output.ErrValidation, "token is required") + } + if !input.Amount.IsPositive() { + return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). + WithDetails("value", input.Amount.String()) + } + + nonce := time.Now().UnixMilli() + action := BuildSpotSendAction(strings.ToLower(input.Destination), input.Token, input.Amount.String(), nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:SpotSend", + spotSendSignTypes, + input.DryRun, + ) +} + +// ApproveAgent executes an approveAgent user-signed action. +func (e *Executor) ApproveAgent(ctx context.Context, input ApproveAgentInput) (json.RawMessage, error) { + if !common.IsHexAddress(input.AgentAddress) { + return nil, output.NewCLIError(output.ErrValidation, "invalid agent address"). + WithDetails("agent", input.AgentAddress) + } + + nonce := time.Now().UnixMilli() + action := BuildApproveAgentAction(strings.ToLower(input.AgentAddress), input.AgentName, nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:ApproveAgent", + approveAgentSignTypes, + input.DryRun, + ) +} + +// UserSetAbstraction executes a userSetAbstraction user-signed action. +func (e *Executor) UserSetAbstraction(ctx context.Context, input UserSetAbstractionInput) (json.RawMessage, error) { + if !common.IsHexAddress(input.User) { + return nil, output.NewCLIError(output.ErrValidation, "invalid user address"). + WithDetails("user", input.User) + } + abstraction := strings.TrimSpace(input.Abstraction) + if abstraction == "" { + return nil, output.NewCLIError(output.ErrValidation, "abstraction is required") + } + if !isSupportedUserAbstraction(abstraction) { + return nil, output.NewCLIError(output.ErrValidation, "unsupported abstraction value"). + WithDetails("value", abstraction). + WithDetails("allowed", []string{"unifiedAccount", "portfolioMargin", "disabled"}) + } + + nonce := time.Now().UnixMilli() + action := BuildUserSetAbstractionAction(strings.ToLower(input.User), abstraction, nonce) + return e.executeUserAction( + ctx, + action, + nonce, + "HyperliquidTransaction:UserSetAbstraction", + userSetAbstractionSignTypes, + input.DryRun, + ) +} diff --git a/pkg/exchange/executor_user_helpers.go b/pkg/exchange/executor_user_helpers.go new file mode 100644 index 0000000..c05b3c3 --- /dev/null +++ b/pkg/exchange/executor_user_helpers.go @@ -0,0 +1,133 @@ +package exchange + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/signer/core/apitypes" + + "github.com/timbrinded/hlgo/pkg/output" +) + +func (e *Executor) executeUserAction( + ctx context.Context, + action any, + nonce int64, + typeName string, + typeFields []apitypes.Type, + dryRun bool, +) (json.RawMessage, error) { + actionMap, err := userActionMap(action) + if err != nil { + return nil, output.NewCLIError(output.ErrAPI, "failed to build action payload"). + WithDetails("cause", err.Error()) + } + + // Keep chain metadata centralized in signer behavior; this payload metadata only + // satisfies exchange request shape and does not participate in typed hashing. + actionMap["signatureChainId"] = userSignatureChainID + actionMap["hyperliquidChain"] = userChain(e.mainnet) + if dryRun { + return json.Marshal(actionMap) + } + + // Sign only schema fields; payload-only metadata (e.g. signatureChainId) stays out. + signMessage := make(map[string]any, len(typeFields)) + for _, field := range typeFields { + if field.Name == "hyperliquidChain" { + continue + } + + value, ok := actionMap[field.Name] + if !ok && (typeName != "HyperliquidTransaction:ApproveAgent" || field.Name != "agentName") { + continue + } + if !ok { + // Revoke flow signs agentName="" but omits the field from the wire payload. + value = "" + } + + if strings.HasPrefix(field.Type, "uint") { + switch v := value.(type) { + case int64: + value = strconv.FormatInt(v, 10) + case int: + value = strconv.Itoa(v) + case uint64: + value = strconv.FormatUint(v, 10) + case uint: + value = strconv.FormatUint(uint64(v), 10) + } + } + signMessage[field.Name] = value + } + + sig, err := e.signer.SignUserAction(typeName, typeFields, signMessage, e.mainnet) + if err != nil { + return nil, err + } + + return e.client.PostExchange(ctx, actionMap, nonce, sigToWire(sig), "", nil) +} + +func userActionMap(action any) (map[string]any, error) { + switch a := action.(type) { + case *USDClassTransferAction: + return map[string]any{ + "type": a.Type, + "amount": a.Amount, + "toPerp": a.ToPerp, + "nonce": a.Nonce, + }, nil + case *Withdraw3Action: + return map[string]any{ + "type": a.Type, + "destination": a.Destination, + "amount": a.Amount, + "time": a.Time, + }, nil + case *ClassTransferAction: + return map[string]any{ + "type": a.Type, + "amount": a.Amount, + "toPerp": a.ToPerp, + "nonce": a.Nonce, + }, nil + case *SpotSendAction: + return map[string]any{ + "type": a.Type, + "destination": a.Destination, + "token": a.Token, + "amount": a.Amount, + "time": a.Time, + }, nil + case *ApproveAgentAction: + m := map[string]any{ + "type": a.Type, + "agentAddress": a.AgentAddress, + "nonce": a.Nonce, + } + if strings.TrimSpace(a.AgentName) != "" { + m["agentName"] = a.AgentName + } + return m, nil + case *UserSetAbstractionAction: + return map[string]any{ + "type": a.Type, + "user": a.User, + "abstraction": a.Abstraction, + "nonce": a.Nonce, + }, nil + default: + return nil, output.NewCLIError(output.ErrAPI, "unsupported user action type") + } +} + +func userChain(mainnet bool) string { + if mainnet { + return "Mainnet" + } + return "Testnet" +} diff --git a/pkg/info/responses.go b/pkg/info/responses.go index 33aa20d..e612023 100644 --- a/pkg/info/responses.go +++ b/pkg/info/responses.go @@ -1,535 +1 @@ package info - -import ( - "encoding/json" - "fmt" - "sort" - "time" - - "github.com/shopspring/decimal" -) - -// annualHours is the number of hours per year, used for APR calculation from hourly rates. -var annualHours = decimal.NewFromInt(8760) - -// MidsResult is a map of coin name to mid-market price string. -type MidsResult map[string]string - -// ParseMidsResult unmarshals raw JSON into a MidsResult. -func ParseMidsResult(raw json.RawMessage) (MidsResult, error) { - var m MidsResult - if err := json.Unmarshal(raw, &m); err != nil { - return nil, fmt.Errorf("parsing mids: %w", err) - } - return m, nil -} - -func (MidsResult) Headers() []string { return []string{"COIN", "MID"} } - -func (m MidsResult) Rows() [][]string { - coins := make([]string, 0, len(m)) - for c := range m { - coins = append(coins, c) - } - sort.Strings(coins) - rows := make([][]string, 0, len(coins)) - for _, c := range coins { - rows = append(rows, []string{c, m[c]}) - } - return rows -} - -// BookLevel represents a single price level in the order book. -type BookLevel struct { - Px string `json:"px"` - Sz string `json:"sz"` - N int `json:"n"` -} - -// BookSide is a list of price levels on one side of the book. -type BookSide struct { - Levels []BookLevel `json:"-"` -} - -// UnmarshalJSON handles the API's nested array format for book sides. -func (bs *BookSide) UnmarshalJSON(data []byte) error { - return json.Unmarshal(data, &bs.Levels) -} - -// BookResult represents the L2 order book response. -type BookResult struct { - Coin string `json:"coin"` - Time int64 `json:"time"` - Levels []BookSide `json:"levels"` -} - -// ParseBookResult unmarshals raw JSON into a BookResult. -func ParseBookResult(raw json.RawMessage) (*BookResult, error) { - var b BookResult - if err := json.Unmarshal(raw, &b); err != nil { - return nil, fmt.Errorf("parsing book: %w", err) - } - return &b, nil -} - -func (*BookResult) Headers() []string { return []string{"SIDE", "PRICE", "SIZE", "COUNT"} } - -func (b *BookResult) Rows() [][]string { - var rows [][]string - if len(b.Levels) > 0 { - for _, lvl := range b.Levels[0].Levels { - rows = append(rows, []string{"bid", lvl.Px, lvl.Sz, fmt.Sprintf("%d", lvl.N)}) - } - } - if len(b.Levels) > 1 { - for _, lvl := range b.Levels[1].Levels { - rows = append(rows, []string{"ask", lvl.Px, lvl.Sz, fmt.Sprintf("%d", lvl.N)}) - } - } - return rows -} - -// Trade represents a single recent trade. -type Trade struct { - Coin string `json:"coin"` - Side string `json:"side"` - Px string `json:"px"` - Sz string `json:"sz"` - Time int64 `json:"time"` - Hash string `json:"hash"` - Tid int64 `json:"tid"` -} - -// TradesResult is a list of recent trades. -type TradesResult []Trade - -// ParseTradesResult unmarshals raw JSON into a TradesResult. -func ParseTradesResult(raw json.RawMessage) (TradesResult, error) { - var t TradesResult - if err := json.Unmarshal(raw, &t); err != nil { - return nil, fmt.Errorf("parsing trades: %w", err) - } - return t, nil -} - -func (TradesResult) Headers() []string { return []string{"TIME", "COIN", "SIDE", "PRICE", "SIZE"} } - -func (t TradesResult) Rows() [][]string { - rows := make([][]string, 0, len(t)) - for _, tr := range t { - rows = append(rows, []string{ - formatTimestamp(tr.Time), - tr.Coin, tr.Side, tr.Px, tr.Sz, - }) - } - return rows -} - -// Candle represents a single OHLCV candle. -type Candle struct { - CloseTime int64 `json:"T"` // close time in ms - C string `json:"c"` // close - H string `json:"h"` // high - Interval string `json:"i"` // interval - L string `json:"l"` // low - N int64 `json:"n"` // trade count - O string `json:"o"` // open - S string `json:"s"` // symbol - OpenTime int64 `json:"t"` // open time in ms - V string `json:"v"` // volume -} - -// CandlesResult is a list of candles. -type CandlesResult []Candle - -// ParseCandlesResult unmarshals raw JSON into a CandlesResult. -func ParseCandlesResult(raw json.RawMessage) (CandlesResult, error) { - var c CandlesResult - if err := json.Unmarshal(raw, &c); err != nil { - return nil, fmt.Errorf("parsing candles: %w", err) - } - return c, nil -} - -func (CandlesResult) Headers() []string { - return []string{"TIME", "OPEN", "HIGH", "LOW", "CLOSE", "VOLUME"} -} - -func (c CandlesResult) Rows() [][]string { - rows := make([][]string, 0, len(c)) - for _, cd := range c { - rows = append(rows, []string{ - formatTimestamp(cd.OpenTime), - cd.O, cd.H, cd.L, cd.C, cd.V, - }) - } - return rows -} - -// Position represents a single perp position in clearinghouse state. -type Position struct { - Coin string `json:"coin"` - Szi string `json:"szi"` - EntryPx string `json:"entryPx"` - UnrealizedPnl string `json:"unrealizedPnl"` - LiquidationPx string `json:"liquidationPx,omitempty"` - Leverage struct { - Type string `json:"type"` - Value int `json:"value"` - RawUSD string `json:"rawUsd,omitempty"` - } `json:"leverage"` -} - -// AssetPosition wraps a position with its type field. -type AssetPosition struct { - Type string `json:"type"` - Position Position `json:"position"` -} - -// MarginSummary represents account-level margin metrics. -type MarginSummary struct { - AccountValue string `json:"accountValue"` - TotalMarginUsed string `json:"totalMarginUsed"` - TotalNtlPos string `json:"totalNtlPos"` - TotalRawUSD string `json:"totalRawUsd"` -} - -// StateResult represents the clearinghouse state response. -type StateResult struct { - AssetPositions []AssetPosition `json:"assetPositions"` - MarginSummary MarginSummary `json:"marginSummary"` - CrossMarginSummary MarginSummary `json:"crossMarginSummary"` - CrossMaintenanceMarginUsed string `json:"crossMaintenanceMarginUsed"` - Withdrawable string `json:"withdrawable"` - Time int64 `json:"time"` -} - -// ParseStateResult unmarshals raw JSON into a StateResult. -func ParseStateResult(raw json.RawMessage) (*StateResult, error) { - var s StateResult - if err := json.Unmarshal(raw, &s); err != nil { - return nil, fmt.Errorf("parsing state: %w", err) - } - return &s, nil -} - -func (*StateResult) Headers() []string { - return []string{"COIN", "SIZE", "ENTRY_PX", "PNL", "LIQ_PRICE", "LEVERAGE"} -} - -func (s *StateResult) Rows() [][]string { - rows := make([][]string, 0, len(s.AssetPositions)) - for _, ap := range s.AssetPositions { - p := ap.Position - lev := fmt.Sprintf("%s(%d)", p.Leverage.Type, p.Leverage.Value) - rows = append(rows, []string{ - p.Coin, p.Szi, p.EntryPx, p.UnrealizedPnl, p.LiquidationPx, lev, - }) - } - return rows -} - -// OpenOrder represents a single open order. -type OpenOrder struct { - Coin string `json:"coin"` - Side string `json:"side"` - LimitPx string `json:"limitPx"` - Sz string `json:"sz"` - Oid int64 `json:"oid"` - Timestamp int64 `json:"timestamp"` - OrderType string `json:"orderType"` - Cloid string `json:"cloid,omitempty"` - IsPositionTpsl bool `json:"isPositionTpsl"` - IsTrigger bool `json:"isTrigger"` - OrigSz string `json:"origSz"` - ReduceOnly bool `json:"reduceOnly"` - TriggerCondition string `json:"triggerCondition"` - TriggerPx string `json:"triggerPx"` -} - -// OpenOrdersResult is a list of open orders. -type OpenOrdersResult []OpenOrder - -// ParseOpenOrdersResult unmarshals raw JSON into an OpenOrdersResult. -func ParseOpenOrdersResult(raw json.RawMessage) (OpenOrdersResult, error) { - var o OpenOrdersResult - if err := json.Unmarshal(raw, &o); err != nil { - return nil, fmt.Errorf("parsing open orders: %w", err) - } - return o, nil -} - -func (OpenOrdersResult) Headers() []string { - return []string{"COIN", "SIDE", "PRICE", "SIZE", "TYPE", "TIME", "OID"} -} - -func (o OpenOrdersResult) Rows() [][]string { - rows := make([][]string, 0, len(o)) - for _, ord := range o { - rows = append(rows, []string{ - ord.Coin, ord.Side, ord.LimitPx, ord.Sz, ord.OrderType, - formatTimestamp(ord.Timestamp), - fmt.Sprintf("%d", ord.Oid), - }) - } - return rows -} - -// Fill represents a single trade fill. -type Fill struct { - Coin string `json:"coin"` - Side string `json:"side"` - Px string `json:"px"` - Sz string `json:"sz"` - Time int64 `json:"time"` - Fee string `json:"fee"` - Oid int64 `json:"oid"` - StartPosition string `json:"startPosition"` - ClosedPnl string `json:"closedPnl"` - Crossed bool `json:"crossed"` - Dir string `json:"dir"` - Hash string `json:"hash"` - FeeToken string `json:"feeToken"` - BuilderFee *string `json:"builderFee,omitempty"` - Tid int64 `json:"tid"` -} - -// FillsResult is a list of fills. -type FillsResult []Fill - -// ParseFillsResult unmarshals raw JSON into a FillsResult. -func ParseFillsResult(raw json.RawMessage) (FillsResult, error) { - var f FillsResult - if err := json.Unmarshal(raw, &f); err != nil { - return nil, fmt.Errorf("parsing fills: %w", err) - } - return f, nil -} - -func (FillsResult) Headers() []string { - return []string{"TIME", "COIN", "SIDE", "PRICE", "SIZE", "FEE", "OID"} -} - -func (f FillsResult) Rows() [][]string { - rows := make([][]string, 0, len(f)) - for _, fl := range f { - rows = append(rows, []string{ - formatTimestamp(fl.Time), - fl.Coin, fl.Side, fl.Px, fl.Sz, fl.Fee, - fmt.Sprintf("%d", fl.Oid), - }) - } - return rows -} - -// UserFundingDelta holds the nested delta payload for user funding entries. -type UserFundingDelta struct { - Type string `json:"type"` - Coin string `json:"coin"` - USDC string `json:"usdc"` - Szi string `json:"szi,omitempty"` - FundingRate string `json:"fundingRate,omitempty"` -} - -// UserFundingEntry represents a single user funding event. -type UserFundingEntry struct { - Time int64 `json:"time"` - Hash string `json:"hash"` - Delta UserFundingDelta `json:"delta"` -} - -// UserFundingResult is a list of user funding events. -type UserFundingResult []UserFundingEntry - -// ParseUserFundingResult unmarshals raw JSON into a UserFundingResult. -func ParseUserFundingResult(raw json.RawMessage) (UserFundingResult, error) { - var result UserFundingResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("parsing user funding: %w", err) - } - return result, nil -} - -// FundingEntry represents a single funding rate entry. -type FundingEntry struct { - Coin string `json:"coin"` - FundingRate string `json:"fundingRate"` - Premium string `json:"premium"` - Time int64 `json:"time"` -} - -// FundingResult is a list of funding entries. -type FundingResult []FundingEntry - -// ParseFundingResult unmarshals raw JSON into a FundingResult. -func ParseFundingResult(raw json.RawMessage) (FundingResult, error) { - var f FundingResult - if err := json.Unmarshal(raw, &f); err != nil { - return nil, fmt.Errorf("parsing funding: %w", err) - } - return f, nil -} - -func (FundingResult) Headers() []string { return []string{"COIN", "TIME", "RATE", "APR"} } - -func (f FundingResult) Rows() [][]string { - rows := make([][]string, 0, len(f)) - for _, fe := range f { - apr := computeAPR(fe.FundingRate) - rows = append(rows, []string{ - fe.Coin, formatTimestamp(fe.Time), fe.FundingRate, apr, - }) - } - return rows -} - -// PredictedFundingVenueDetails holds venue funding details. -type PredictedFundingVenueDetails struct { - FundingRate string `json:"fundingRate"` - NextFundingTime int64 `json:"nextFundingTime"` -} - -// PredictedFundingVenue represents one venue entry for a coin. -type PredictedFundingVenue struct { - Venue string - Details PredictedFundingVenueDetails -} - -// PredictedFundingCoin represents one coin entry with all venue predictions. -type PredictedFundingCoin struct { - Coin string - Venues []PredictedFundingVenue -} - -// PredictedFundingsResult wraps the nested predicted funding response. -// API shape: -// [ -// -// ["AVAX", [["BinPerp", {...}], ["HlPerp", {...}]]], -// ... -// -// ] -type PredictedFundingsResult []PredictedFundingCoin - -// ParsePredictedFundingsResult unmarshals raw JSON into a PredictedFundingsResult. -func ParsePredictedFundingsResult(raw json.RawMessage) (PredictedFundingsResult, error) { - var outer []json.RawMessage - if err := json.Unmarshal(raw, &outer); err != nil { - return nil, fmt.Errorf("parsing predicted fundings: %w", err) - } - - result := make(PredictedFundingsResult, 0, len(outer)) - for i, coinEntryRaw := range outer { - var coinEntry []json.RawMessage - if err := json.Unmarshal(coinEntryRaw, &coinEntry); err != nil { - return nil, fmt.Errorf("parsing predicted fundings coin entry %d: %w", i, err) - } - if len(coinEntry) != 2 { - return nil, fmt.Errorf("parsing predicted fundings coin entry %d: expected length 2, got %d", i, len(coinEntry)) - } - - var coin string - if err := json.Unmarshal(coinEntry[0], &coin); err != nil { - return nil, fmt.Errorf("parsing predicted fundings coin name %d: %w", i, err) - } - - var venuesRaw []json.RawMessage - if err := json.Unmarshal(coinEntry[1], &venuesRaw); err != nil { - return nil, fmt.Errorf("parsing predicted fundings venues for %s: %w", coin, err) - } - - venues := make([]PredictedFundingVenue, 0, len(venuesRaw)) - for j, venueEntryRaw := range venuesRaw { - var venueEntry []json.RawMessage - if err := json.Unmarshal(venueEntryRaw, &venueEntry); err != nil { - return nil, fmt.Errorf("parsing predicted fundings venue entry %s[%d]: %w", coin, j, err) - } - if len(venueEntry) != 2 { - return nil, fmt.Errorf("parsing predicted fundings venue entry %s[%d]: expected length 2, got %d", coin, j, len(venueEntry)) - } - - var venue string - if err := json.Unmarshal(venueEntry[0], &venue); err != nil { - return nil, fmt.Errorf("parsing predicted fundings venue name %s[%d]: %w", coin, j, err) - } - - var details PredictedFundingVenueDetails - if err := json.Unmarshal(venueEntry[1], &details); err != nil { - return nil, fmt.Errorf("parsing predicted fundings venue details %s[%d]: %w", coin, j, err) - } - - venues = append(venues, PredictedFundingVenue{ - Venue: venue, - Details: details, - }) - } - - result = append(result, PredictedFundingCoin{ - Coin: coin, - Venues: venues, - }) - } - - return result, nil -} - -func (PredictedFundingsResult) Headers() []string { - return []string{"COIN", "VENUE", "PREDICTED_RATE", "APR"} -} - -func (p PredictedFundingsResult) Rows() [][]string { - rows := make([][]string, 0, len(p)) - for _, coinEntry := range p { - for _, venueEntry := range coinEntry.Venues { - rate := venueEntry.Details.FundingRate - apr := computeAPR(rate) - rows = append(rows, []string{coinEntry.Coin, venueEntry.Venue, rate, apr}) - } - } - return rows -} - -// PerpDex represents a HIP-3 perp dex. -type PerpDex struct { - Name string `json:"name"` - Index int `json:"index"` - NumMarkets int `json:"numMarkets"` -} - -// PerpDexsResult is a list of perp dexes. -type PerpDexsResult []PerpDex - -// ParsePerpDexsResult unmarshals raw JSON into a PerpDexsResult. -func ParsePerpDexsResult(raw json.RawMessage) (PerpDexsResult, error) { - var p PerpDexsResult - if err := json.Unmarshal(raw, &p); err != nil { - return nil, fmt.Errorf("parsing perp dexs: %w", err) - } - return p, nil -} - -func (PerpDexsResult) Headers() []string { return []string{"NAME", "INDEX", "NUM_MARKETS"} } - -func (p PerpDexsResult) Rows() [][]string { - rows := make([][]string, 0, len(p)) - for _, d := range p { - rows = append(rows, []string{ - d.Name, fmt.Sprintf("%d", d.Index), fmt.Sprintf("%d", d.NumMarkets), - }) - } - return rows -} - -// computeAPR converts an hourly funding rate string to an annualized percentage. -func computeAPR(rateStr string) string { - rate, err := decimal.NewFromString(rateStr) - if err != nil { - return "N/A" - } - return rate.Mul(annualHours).String() -} - -// formatTimestamp formats a Unix millisecond timestamp as RFC3339. -func formatTimestamp(ms int64) string { - return time.UnixMilli(ms).UTC().Format(time.RFC3339) -} diff --git a/pkg/info/responses_account.go b/pkg/info/responses_account.go new file mode 100644 index 0000000..f26231b --- /dev/null +++ b/pkg/info/responses_account.go @@ -0,0 +1,175 @@ +package info + +import ( + "encoding/json" + "fmt" +) + +// Position represents a single perp position in clearinghouse state. +type Position struct { + Coin string `json:"coin"` + Szi string `json:"szi"` + EntryPx string `json:"entryPx"` + UnrealizedPnl string `json:"unrealizedPnl"` + LiquidationPx string `json:"liquidationPx,omitempty"` + Leverage struct { + Type string `json:"type"` + Value int `json:"value"` + RawUSD string `json:"rawUsd,omitempty"` + } `json:"leverage"` +} + +// AssetPosition wraps a position with its type field. +type AssetPosition struct { + Type string `json:"type"` + Position Position `json:"position"` +} + +// MarginSummary represents account-level margin metrics. +type MarginSummary struct { + AccountValue string `json:"accountValue"` + TotalMarginUsed string `json:"totalMarginUsed"` + TotalNtlPos string `json:"totalNtlPos"` + TotalRawUSD string `json:"totalRawUsd"` +} + +// StateResult represents the clearinghouse state response. +type StateResult struct { + AssetPositions []AssetPosition `json:"assetPositions"` + MarginSummary MarginSummary `json:"marginSummary"` + CrossMarginSummary MarginSummary `json:"crossMarginSummary"` + CrossMaintenanceMarginUsed string `json:"crossMaintenanceMarginUsed"` + Withdrawable string `json:"withdrawable"` + Time int64 `json:"time"` +} + +// ParseStateResult unmarshals raw JSON into a StateResult. +func ParseStateResult(raw json.RawMessage) (*StateResult, error) { + var state StateResult + if err := json.Unmarshal(raw, &state); err != nil { + return nil, fmt.Errorf("parsing state: %w", err) + } + return &state, nil +} + +func (*StateResult) Headers() []string { + return []string{"COIN", "SIZE", "ENTRY_PX", "PNL", "LIQ_PRICE", "LEVERAGE"} +} + +func (s *StateResult) Rows() [][]string { + rows := make([][]string, 0, len(s.AssetPositions)) + for _, assetPosition := range s.AssetPositions { + position := assetPosition.Position + leverage := fmt.Sprintf("%s(%d)", position.Leverage.Type, position.Leverage.Value) + rows = append(rows, []string{ + position.Coin, + position.Szi, + position.EntryPx, + position.UnrealizedPnl, + position.LiquidationPx, + leverage, + }) + } + return rows +} + +// OpenOrder represents a single open order. +type OpenOrder struct { + Coin string `json:"coin"` + Side string `json:"side"` + LimitPx string `json:"limitPx"` + Sz string `json:"sz"` + Oid int64 `json:"oid"` + Timestamp int64 `json:"timestamp"` + OrderType string `json:"orderType"` + Cloid string `json:"cloid,omitempty"` + IsPositionTpsl bool `json:"isPositionTpsl"` + IsTrigger bool `json:"isTrigger"` + OrigSz string `json:"origSz"` + ReduceOnly bool `json:"reduceOnly"` + TriggerCondition string `json:"triggerCondition"` + TriggerPx string `json:"triggerPx"` +} + +// OpenOrdersResult is a list of open orders. +type OpenOrdersResult []OpenOrder + +// ParseOpenOrdersResult unmarshals raw JSON into an OpenOrdersResult. +func ParseOpenOrdersResult(raw json.RawMessage) (OpenOrdersResult, error) { + var orders OpenOrdersResult + if err := json.Unmarshal(raw, &orders); err != nil { + return nil, fmt.Errorf("parsing open orders: %w", err) + } + return orders, nil +} + +func (OpenOrdersResult) Headers() []string { + return []string{"COIN", "SIDE", "PRICE", "SIZE", "TYPE", "TIME", "OID"} +} + +func (o OpenOrdersResult) Rows() [][]string { + rows := make([][]string, 0, len(o)) + for _, order := range o { + rows = append(rows, []string{ + order.Coin, + order.Side, + order.LimitPx, + order.Sz, + order.OrderType, + formatTimestamp(order.Timestamp), + fmt.Sprintf("%d", order.Oid), + }) + } + return rows +} + +// Fill represents a single trade fill. +type Fill struct { + Coin string `json:"coin"` + Side string `json:"side"` + Px string `json:"px"` + Sz string `json:"sz"` + Time int64 `json:"time"` + Fee string `json:"fee"` + Oid int64 `json:"oid"` + StartPosition string `json:"startPosition"` + ClosedPnl string `json:"closedPnl"` + Crossed bool `json:"crossed"` + Dir string `json:"dir"` + Hash string `json:"hash"` + FeeToken string `json:"feeToken"` + BuilderFee *string `json:"builderFee,omitempty"` + Tid int64 `json:"tid"` +} + +// FillsResult is a list of fills. +type FillsResult []Fill + +// ParseFillsResult unmarshals raw JSON into a FillsResult. +func ParseFillsResult(raw json.RawMessage) (FillsResult, error) { + var fills FillsResult + if err := json.Unmarshal(raw, &fills); err != nil { + return nil, fmt.Errorf("parsing fills: %w", err) + } + return fills, nil +} + +func (FillsResult) Headers() []string { + return []string{"TIME", "COIN", "SIDE", "PRICE", "SIZE", "FEE", "OID"} +} + +func (f FillsResult) Rows() [][]string { + rows := make([][]string, 0, len(f)) + for _, fill := range f { + rows = append(rows, []string{ + formatTimestamp(fill.Time), + fill.Coin, + fill.Side, + fill.Px, + fill.Sz, + fill.Fee, + fmt.Sprintf("%d", fill.Oid), + }) + } + return rows +} diff --git a/pkg/info/responses_funding.go b/pkg/info/responses_funding.go new file mode 100644 index 0000000..25b298a --- /dev/null +++ b/pkg/info/responses_funding.go @@ -0,0 +1,211 @@ +package info + +import ( + "encoding/json" + "fmt" + + "github.com/shopspring/decimal" +) + +// annualHours is the number of hours per year, used for APR calculation from hourly rates. +var annualHours = decimal.NewFromInt(8760) + +// UserFundingDelta holds the nested delta payload for user funding entries. +type UserFundingDelta struct { + Type string `json:"type"` + Coin string `json:"coin"` + USDC string `json:"usdc"` + Szi string `json:"szi,omitempty"` + FundingRate string `json:"fundingRate,omitempty"` +} + +// UserFundingEntry represents a single user funding event. +type UserFundingEntry struct { + Time int64 `json:"time"` + Hash string `json:"hash"` + Delta UserFundingDelta `json:"delta"` +} + +// UserFundingResult is a list of user funding events. +type UserFundingResult []UserFundingEntry + +// ParseUserFundingResult unmarshals raw JSON into a UserFundingResult. +func ParseUserFundingResult(raw json.RawMessage) (UserFundingResult, error) { + var result UserFundingResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("parsing user funding: %w", err) + } + return result, nil +} + +// FundingEntry represents a single funding rate entry. +type FundingEntry struct { + Coin string `json:"coin"` + FundingRate string `json:"fundingRate"` + Premium string `json:"premium"` + Time int64 `json:"time"` +} + +// FundingResult is a list of funding entries. +type FundingResult []FundingEntry + +// ParseFundingResult unmarshals raw JSON into a FundingResult. +func ParseFundingResult(raw json.RawMessage) (FundingResult, error) { + var funding FundingResult + if err := json.Unmarshal(raw, &funding); err != nil { + return nil, fmt.Errorf("parsing funding: %w", err) + } + return funding, nil +} + +func (FundingResult) Headers() []string { return []string{"COIN", "TIME", "RATE", "APR"} } + +func (f FundingResult) Rows() [][]string { + rows := make([][]string, 0, len(f)) + for _, entry := range f { + rows = append(rows, []string{ + entry.Coin, + formatTimestamp(entry.Time), + entry.FundingRate, + computeAPR(entry.FundingRate), + }) + } + return rows +} + +// PredictedFundingVenueDetails holds venue funding details. +type PredictedFundingVenueDetails struct { + FundingRate string `json:"fundingRate"` + NextFundingTime int64 `json:"nextFundingTime"` +} + +// PredictedFundingVenue represents one venue entry for a coin. +type PredictedFundingVenue struct { + Venue string + Details PredictedFundingVenueDetails +} + +// PredictedFundingCoin represents one coin entry with all venue predictions. +type PredictedFundingCoin struct { + Coin string + Venues []PredictedFundingVenue +} + +// PredictedFundingsResult wraps the nested predicted funding response. +// API shape: +// [ +// +// ["AVAX", [["BinPerp", {...}], ["HlPerp", {...}]]], +// ... +// +// ] +type PredictedFundingsResult []PredictedFundingCoin + +// ParsePredictedFundingsResult unmarshals raw JSON into a PredictedFundingsResult. +func ParsePredictedFundingsResult(raw json.RawMessage) (PredictedFundingsResult, error) { + var outer []json.RawMessage + if err := json.Unmarshal(raw, &outer); err != nil { + return nil, fmt.Errorf("parsing predicted fundings: %w", err) + } + + result := make(PredictedFundingsResult, 0, len(outer)) + for i, coinEntryRaw := range outer { + var coinEntry []json.RawMessage + if err := json.Unmarshal(coinEntryRaw, &coinEntry); err != nil { + return nil, fmt.Errorf("parsing predicted fundings coin entry %d: %w", i, err) + } + if len(coinEntry) != 2 { + return nil, fmt.Errorf("parsing predicted fundings coin entry %d: expected length 2, got %d", i, len(coinEntry)) + } + + var coin string + if err := json.Unmarshal(coinEntry[0], &coin); err != nil { + return nil, fmt.Errorf("parsing predicted fundings coin name %d: %w", i, err) + } + + var venuesRaw []json.RawMessage + if err := json.Unmarshal(coinEntry[1], &venuesRaw); err != nil { + return nil, fmt.Errorf("parsing predicted fundings venues for %s: %w", coin, err) + } + + venues := make([]PredictedFundingVenue, 0, len(venuesRaw)) + for j, venueEntryRaw := range venuesRaw { + var venueEntry []json.RawMessage + if err := json.Unmarshal(venueEntryRaw, &venueEntry); err != nil { + return nil, fmt.Errorf("parsing predicted fundings venue entry %s[%d]: %w", coin, j, err) + } + if len(venueEntry) != 2 { + return nil, fmt.Errorf("parsing predicted fundings venue entry %s[%d]: expected length 2, got %d", coin, j, len(venueEntry)) + } + + var venue string + if err := json.Unmarshal(venueEntry[0], &venue); err != nil { + return nil, fmt.Errorf("parsing predicted fundings venue name %s[%d]: %w", coin, j, err) + } + + var details PredictedFundingVenueDetails + if err := json.Unmarshal(venueEntry[1], &details); err != nil { + return nil, fmt.Errorf("parsing predicted fundings venue details %s[%d]: %w", coin, j, err) + } + + venues = append(venues, PredictedFundingVenue{Venue: venue, Details: details}) + } + + result = append(result, PredictedFundingCoin{Coin: coin, Venues: venues}) + } + + return result, nil +} + +func (PredictedFundingsResult) Headers() []string { + return []string{"COIN", "VENUE", "PREDICTED_RATE", "APR"} +} + +func (p PredictedFundingsResult) Rows() [][]string { + rows := make([][]string, 0, len(p)) + for _, coinEntry := range p { + for _, venueEntry := range coinEntry.Venues { + rate := venueEntry.Details.FundingRate + rows = append(rows, []string{coinEntry.Coin, venueEntry.Venue, rate, computeAPR(rate)}) + } + } + return rows +} + +// PerpDex represents a HIP-3 perp dex. +type PerpDex struct { + Name string `json:"name"` + Index int `json:"index"` + NumMarkets int `json:"numMarkets"` +} + +// PerpDexsResult is a list of perp dexes. +type PerpDexsResult []PerpDex + +// ParsePerpDexsResult unmarshals raw JSON into a PerpDexsResult. +func ParsePerpDexsResult(raw json.RawMessage) (PerpDexsResult, error) { + var dexs PerpDexsResult + if err := json.Unmarshal(raw, &dexs); err != nil { + return nil, fmt.Errorf("parsing perp dexs: %w", err) + } + return dexs, nil +} + +func (PerpDexsResult) Headers() []string { return []string{"NAME", "INDEX", "NUM_MARKETS"} } + +func (p PerpDexsResult) Rows() [][]string { + rows := make([][]string, 0, len(p)) + for _, dex := range p { + rows = append(rows, []string{dex.Name, fmt.Sprintf("%d", dex.Index), fmt.Sprintf("%d", dex.NumMarkets)}) + } + return rows +} + +// computeAPR converts an hourly funding rate string to an annualized percentage. +func computeAPR(rateStr string) string { + rate, err := decimal.NewFromString(rateStr) + if err != nil { + return "N/A" + } + return rate.Mul(annualHours).String() +} diff --git a/pkg/info/responses_market.go b/pkg/info/responses_market.go new file mode 100644 index 0000000..6221b49 --- /dev/null +++ b/pkg/info/responses_market.go @@ -0,0 +1,167 @@ +package info + +import ( + "encoding/json" + "fmt" + "sort" + "time" +) + +// MidsResult is a map of coin name to mid-market price string. +type MidsResult map[string]string + +// ParseMidsResult unmarshals raw JSON into a MidsResult. +func ParseMidsResult(raw json.RawMessage) (MidsResult, error) { + var mids MidsResult + if err := json.Unmarshal(raw, &mids); err != nil { + return nil, fmt.Errorf("parsing mids: %w", err) + } + return mids, nil +} + +func (MidsResult) Headers() []string { return []string{"COIN", "MID"} } + +func (m MidsResult) Rows() [][]string { + coins := make([]string, 0, len(m)) + for coin := range m { + coins = append(coins, coin) + } + sort.Strings(coins) + rows := make([][]string, 0, len(coins)) + for _, coin := range coins { + rows = append(rows, []string{coin, m[coin]}) + } + return rows +} + +// BookLevel represents a single price level in the order book. +type BookLevel struct { + Px string `json:"px"` + Sz string `json:"sz"` + N int `json:"n"` +} + +// BookSide is a list of price levels on one side of the book. +type BookSide struct { + Levels []BookLevel `json:"-"` +} + +// UnmarshalJSON handles the API's nested array format for book sides. +func (bs *BookSide) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &bs.Levels) +} + +// BookResult represents the L2 order book response. +type BookResult struct { + Coin string `json:"coin"` + Time int64 `json:"time"` + Levels []BookSide `json:"levels"` +} + +// ParseBookResult unmarshals raw JSON into a BookResult. +func ParseBookResult(raw json.RawMessage) (*BookResult, error) { + var book BookResult + if err := json.Unmarshal(raw, &book); err != nil { + return nil, fmt.Errorf("parsing book: %w", err) + } + return &book, nil +} + +func (*BookResult) Headers() []string { return []string{"SIDE", "PRICE", "SIZE", "COUNT"} } + +func (b *BookResult) Rows() [][]string { + var rows [][]string + if len(b.Levels) > 0 { + for _, level := range b.Levels[0].Levels { + rows = append(rows, []string{"bid", level.Px, level.Sz, fmt.Sprintf("%d", level.N)}) + } + } + if len(b.Levels) > 1 { + for _, level := range b.Levels[1].Levels { + rows = append(rows, []string{"ask", level.Px, level.Sz, fmt.Sprintf("%d", level.N)}) + } + } + return rows +} + +// Trade represents a single recent trade. +type Trade struct { + Coin string `json:"coin"` + Side string `json:"side"` + Px string `json:"px"` + Sz string `json:"sz"` + Time int64 `json:"time"` + Hash string `json:"hash"` + Tid int64 `json:"tid"` +} + +// TradesResult is a list of recent trades. +type TradesResult []Trade + +// ParseTradesResult unmarshals raw JSON into a TradesResult. +func ParseTradesResult(raw json.RawMessage) (TradesResult, error) { + var trades TradesResult + if err := json.Unmarshal(raw, &trades); err != nil { + return nil, fmt.Errorf("parsing trades: %w", err) + } + return trades, nil +} + +func (TradesResult) Headers() []string { return []string{"TIME", "COIN", "SIDE", "PRICE", "SIZE"} } + +func (t TradesResult) Rows() [][]string { + rows := make([][]string, 0, len(t)) + for _, trade := range t { + rows = append(rows, []string{ + formatTimestamp(trade.Time), + trade.Coin, trade.Side, trade.Px, trade.Sz, + }) + } + return rows +} + +// Candle represents a single OHLCV candle. +type Candle struct { + CloseTime int64 `json:"T"` // close time in ms + C string `json:"c"` // close + H string `json:"h"` // high + Interval string `json:"i"` // interval + L string `json:"l"` // low + N int64 `json:"n"` // trade count + O string `json:"o"` // open + S string `json:"s"` // symbol + OpenTime int64 `json:"t"` // open time in ms + V string `json:"v"` // volume +} + +// CandlesResult is a list of candles. +type CandlesResult []Candle + +// ParseCandlesResult unmarshals raw JSON into a CandlesResult. +func ParseCandlesResult(raw json.RawMessage) (CandlesResult, error) { + var candles CandlesResult + if err := json.Unmarshal(raw, &candles); err != nil { + return nil, fmt.Errorf("parsing candles: %w", err) + } + return candles, nil +} + +func (CandlesResult) Headers() []string { + return []string{"TIME", "OPEN", "HIGH", "LOW", "CLOSE", "VOLUME"} +} + +func (c CandlesResult) Rows() [][]string { + rows := make([][]string, 0, len(c)) + for _, candle := range c { + rows = append(rows, []string{ + formatTimestamp(candle.OpenTime), + candle.O, candle.H, candle.L, candle.C, candle.V, + }) + } + return rows +} + +// formatTimestamp formats a Unix millisecond timestamp as RFC3339. +func formatTimestamp(ms int64) string { + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} diff --git a/pkg/output/errors.go b/pkg/output/errors.go index c47b930..657ca70 100644 --- a/pkg/output/errors.go +++ b/pkg/output/errors.go @@ -65,10 +65,7 @@ func (e *CLIError) WithDetails(key string, value any) *CLIError { // NewCLIError creates a new CLIError with the given code and message. func NewCLIError(code ErrorCode, message string) *CLIError { - return &CLIError{ - Code: code, - Message: message, - } + return &CLIError{Code: code, Message: message} } // WriteError serializes err as structured JSON to w and returns the exit code. diff --git a/pkg/resolver/cache.go b/pkg/resolver/cache.go index bbfda3c..f19258f 100644 --- a/pkg/resolver/cache.go +++ b/pkg/resolver/cache.go @@ -52,10 +52,7 @@ func (c *diskCache) write(filename string, data []byte, now time.Time) { return } - entry := cacheEntry{ - Timestamp: now, - Data: data, - } + entry := cacheEntry{Timestamp: now, Data: data} raw, err := json.Marshal(entry) if err != nil { return diff --git a/pkg/resolver/metadata.go b/pkg/resolver/metadata.go new file mode 100644 index 0000000..6df8340 --- /dev/null +++ b/pkg/resolver/metadata.go @@ -0,0 +1,175 @@ +package resolver + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/timbrinded/hlgo/pkg/output" +) + +// perpMeta is the response shape from POST /info {"type":"meta"}. +type perpMeta struct { + Universe []perpAsset `json:"universe"` +} + +// perpAsset is a single entry in the perp universe array. +type perpAsset struct { + Name string `json:"name"` + SzDecimals int `json:"szDecimals"` +} + +func (r *CachingResolver) loadCoreMetadataLocked(ctx context.Context) error { + now := r.nowFunc() + + // Try loading from disk cache first. + perpData, perpFresh := r.cache.read("meta.json", r.ttl, now) + spotData, spotFresh := r.cache.read("spot_meta.json", r.ttl, now) + + if perpFresh && spotFresh { + if err := r.buildMaps(perpData, spotData); err == nil { + r.loadedCore = true + return nil + } + // Corrupt cache — fall through to fetch. + } + + // Fetch from API. + perpData, err := r.fetchMetaWithTimeout(ctx, "perp_meta", "meta", "", r.coreTimeout) + if err != nil { + return err + } + spotData, err = r.fetchMetaWithTimeout(ctx, "spot_meta", "spotMeta", "", r.coreTimeout) + if err != nil { + return err + } + + if err := r.buildMaps(perpData, spotData); err != nil { + return err + } + + // Write cache (best-effort — failure here is non-fatal). + r.cache.write("meta.json", perpData, now) + r.cache.write("spot_meta.json", spotData, now) + + r.loadedCore = true + return nil +} + +func (r *CachingResolver) loadHIP3DexLocked(ctx context.Context, dex string) error { + offsets, err := r.fetchPerpDexOffsetsWithTimeout(ctx) + if err != nil { + return err + } + + offset, ok := offsets[dex] + if !ok { + return output.NewCLIError(output.ErrValidation, "unknown HIP-3 dex: "+dex). + WithDetails("dex", dex) + } + + metaRaw, err := r.fetchMetaWithTimeout(ctx, "hip3_meta", "meta", dex, r.hip3Timeout) + if err != nil { + return err + } + + var metadata perpMeta + if err := json.Unmarshal(metaRaw, &metadata); err != nil { + return output.NewCLIError(output.ErrAPI, "failed to parse HIP-3 metadata"). + WithDetails("dex", dex). + WithDetails("cause", err.Error()) + } + + for index, asset := range metadata.Universe { + upper := strings.ToUpper(asset.Name) + r.perpMap[upper] = &AssetInfo{ + AssetID: offset + index, + Coin: asset.Name, + CanonicalCoin: asset.Name, + SzDecimals: asset.SzDecimals, + IsSpot: false, + } + } + + return nil +} + +// fetchMeta sends a typed info request and returns the raw JSON bytes. +func (r *CachingResolver) fetchMeta(ctx context.Context, metaType, dex string) ([]byte, error) { + request := map[string]string{"type": metaType} + if dex != "" { + request["dex"] = dex + } + return r.client.PostInfo(ctx, request) +} + +func (r *CachingResolver) fetchMetaWithTimeout(ctx context.Context, stage, metaType, dex string, timeout time.Duration) ([]byte, error) { + requestCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + raw, err := r.fetchMeta(requestCtx, metaType, dex) + if err == nil { + return raw, nil + } + + if errors.Is(requestCtx.Err(), context.DeadlineExceeded) { + return nil, output.NewCLIError(output.ErrNetwork, "metadata request timed out"). + WithDetails("stage", stage). + WithDetails("type", metaType). + WithDetails("dex", dex). + WithDetails("timeout_ms", timeout.Milliseconds()) + } + + return nil, wrapMetadataError(err, stage, metaType, dex, timeout) +} + +func (r *CachingResolver) fetchPerpDexOffsetsWithTimeout(ctx context.Context) (map[string]int, error) { + requestCtx, cancel := context.WithTimeout(ctx, r.hip3Timeout) + defer cancel() + + offsets, err := r.fetchPerpDexOffsets(requestCtx) + if err == nil { + return offsets, nil + } + + if errors.Is(requestCtx.Err(), context.DeadlineExceeded) { + return nil, output.NewCLIError(output.ErrNetwork, "metadata request timed out"). + WithDetails("stage", "hip3_perp_dexs"). + WithDetails("type", "perpDexs"). + WithDetails("timeout_ms", r.hip3Timeout.Milliseconds()) + } + + return nil, wrapMetadataError(err, "hip3_perp_dexs", "perpDexs", "", r.hip3Timeout) +} + +func wrapMetadataError(err error, stage, metaType, dex string, timeout time.Duration) error { + var cliErr *output.CLIError + if errors.As(err, &cliErr) { + wrapped := output.NewCLIError(cliErr.Code, cliErr.Message). + WithDetails("stage", stage). + WithDetails("type", metaType). + WithDetails("dex", dex). + WithDetails("timeout_ms", timeout.Milliseconds()) + for key, value := range cliErr.Details { + wrapped = wrapped.WithDetails(key, value) + } + return wrapped + } + + return output.NewCLIError(output.ErrNetwork, "metadata request failed"). + WithDetails("stage", stage). + WithDetails("type", metaType). + WithDetails("dex", dex). + WithDetails("timeout_ms", timeout.Milliseconds()). + WithDetails("cause", err.Error()) +} + +func parseHIP3Dex(coin string) string { + idx := strings.Index(coin, ":") + if idx <= 0 { + return "" + } + return strings.ToLower(strings.TrimSpace(coin[:idx])) +} diff --git a/pkg/resolver/resolver.go b/pkg/resolver/resolver.go index e44c0e3..11cb10f 100644 --- a/pkg/resolver/resolver.go +++ b/pkg/resolver/resolver.go @@ -9,8 +9,6 @@ package resolver import ( "context" "encoding/json" - "errors" - "sort" "strconv" "strings" "sync" @@ -51,52 +49,6 @@ type AssetInfo struct { Passthrough bool // true when resolved from a numeric ID string (metadata unknown) } -type spotPairCandidate struct { - info *AssetInfo - marketName string - marketIndex int - canonical bool -} - -// perpMeta is the response shape from POST /info {"type":"meta"}. -type perpMeta struct { - Universe []perpAsset `json:"universe"` -} - -type perpDex struct { - Name string `json:"name"` -} - -// perpAsset is a single entry in the perp universe array. -type perpAsset struct { - Name string `json:"name"` - SzDecimals int `json:"szDecimals"` -} - -// spotMeta is the response shape from POST /info {"type":"spotMeta"}. -// The real API returns a two-level structure: a flat top-level "tokens" registry -// and universe entries that reference tokens by integer index. -type spotMeta struct { - Universe []spotMarket `json:"universe"` - Tokens []spotToken `json:"tokens"` // top-level token registry -} - -// spotMarket is a single spot market in the spot universe array. -type spotMarket struct { - Name string `json:"name"` - Index int `json:"index"` - Tokens []int `json:"tokens"` // indices into spotMeta.Tokens - IsCanonical bool `json:"isCanonical"` // optional on some API responses -} - -// spotToken is a token within a spot market. -type spotToken struct { - Name string `json:"name"` - Index int `json:"index"` - SzDecimals int `json:"szDecimals"` - FullName string `json:"fullName"` -} - // CachingResolver implements Resolver with a disk-backed, TTL-based cache. // It is safe for concurrent use. type CachingResolver struct { @@ -117,18 +69,7 @@ type CachingResolver struct { // NewResolver creates a CachingResolver that fetches metadata via client, // caches to cacheDir with the given TTL. func NewResolver(c InfoFetcher, cacheDir string, ttl time.Duration) *CachingResolver { - return &CachingResolver{ - client: c, - cache: newDiskCache(cacheDir), - ttl: ttl, - perpMap: make(map[string]*AssetInfo), - spotMap: make(map[string]*AssetInfo), - spotPairAliases: make(map[string][]spotPairCandidate), - loadedHIP3Dex: make(map[string]bool), - nowFunc: time.Now, - coreTimeout: defaultCoreMetadataTimeout, - hip3Timeout: defaultHIP3MetadataTimeout, - } + return &CachingResolver{client: c, cache: newDiskCache(cacheDir), ttl: ttl, perpMap: make(map[string]*AssetInfo), spotMap: make(map[string]*AssetInfo), spotPairAliases: make(map[string][]spotPairCandidate), loadedHIP3Dex: make(map[string]bool), nowFunc: time.Now, coreTimeout: defaultCoreMetadataTimeout, hip3Timeout: defaultHIP3MetadataTimeout} } // ResolveAsset resolves a coin name to its asset info. Numeric strings are @@ -146,14 +87,7 @@ func (r *CachingResolver) ResolveAsset(ctx context.Context, coin string) (*Asset WithDetails("coin", coin). WithDetails("hint", "use a non-negative numeric asset ID or a valid coin name (e.g. BTC, ETH)") } - return &AssetInfo{ - AssetID: id, - Coin: trimmed, - CanonicalCoin: trimmed, - SzDecimals: 0, - IsSpot: false, - Passthrough: true, - }, nil + return &AssetInfo{AssetID: id, Coin: trimmed, CanonicalCoin: trimmed, SzDecimals: 0, IsSpot: false, Passthrough: true}, nil } } @@ -222,350 +156,3 @@ func (r *CachingResolver) ensureLoaded(ctx context.Context, targetHIP3Dex string return nil } - -func (r *CachingResolver) loadCoreMetadataLocked(ctx context.Context) error { - now := r.nowFunc() - - // Try loading from disk cache first. - perpData, perpFresh := r.cache.read("meta.json", r.ttl, now) - spotData, spotFresh := r.cache.read("spot_meta.json", r.ttl, now) - - if perpFresh && spotFresh { - if err := r.buildMaps(perpData, spotData); err == nil { - r.loadedCore = true - return nil - } - // Corrupt cache — fall through to fetch. - } - - // Fetch from API. - perpData, err := r.fetchMetaWithTimeout(ctx, "perp_meta", "meta", "", r.coreTimeout) - if err != nil { - return err - } - spotData, err = r.fetchMetaWithTimeout(ctx, "spot_meta", "spotMeta", "", r.coreTimeout) - if err != nil { - return err - } - - if err := r.buildMaps(perpData, spotData); err != nil { - return err - } - - // Write cache (best-effort — failure here is non-fatal). - r.cache.write("meta.json", perpData, now) - r.cache.write("spot_meta.json", spotData, now) - - r.loadedCore = true - return nil -} - -func (r *CachingResolver) loadHIP3DexLocked(ctx context.Context, dex string) error { - offsets, err := r.fetchPerpDexOffsetsWithTimeout(ctx) - if err != nil { - return err - } - - offset, ok := offsets[dex] - if !ok { - return output.NewCLIError(output.ErrValidation, "unknown HIP-3 dex: "+dex). - WithDetails("dex", dex) - } - - metaRaw, err := r.fetchMetaWithTimeout(ctx, "hip3_meta", "meta", dex, r.hip3Timeout) - if err != nil { - return err - } - - var pm perpMeta - if err := json.Unmarshal(metaRaw, &pm); err != nil { - return output.NewCLIError(output.ErrAPI, "failed to parse HIP-3 metadata"). - WithDetails("dex", dex). - WithDetails("cause", err.Error()) - } - - for i, asset := range pm.Universe { - upper := strings.ToUpper(asset.Name) - r.perpMap[upper] = &AssetInfo{ - AssetID: offset + i, - Coin: asset.Name, - CanonicalCoin: asset.Name, - SzDecimals: asset.SzDecimals, - IsSpot: false, - } - } - - return nil -} - -// fetchMeta sends a typed info request and returns the raw JSON bytes. -func (r *CachingResolver) fetchMeta(ctx context.Context, metaType, dex string) ([]byte, error) { - req := map[string]string{"type": metaType} - if dex != "" { - req["dex"] = dex - } - return r.client.PostInfo(ctx, req) -} - -func (r *CachingResolver) fetchMetaWithTimeout(ctx context.Context, stage, metaType, dex string, timeout time.Duration) ([]byte, error) { - requestCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - raw, err := r.fetchMeta(requestCtx, metaType, dex) - if err == nil { - return raw, nil - } - - if errors.Is(requestCtx.Err(), context.DeadlineExceeded) { - return nil, output.NewCLIError(output.ErrNetwork, "metadata request timed out"). - WithDetails("stage", stage). - WithDetails("type", metaType). - WithDetails("dex", dex). - WithDetails("timeout_ms", timeout.Milliseconds()) - } - - return nil, wrapMetadataError(err, stage, metaType, dex, timeout) -} - -func (r *CachingResolver) fetchPerpDexOffsetsWithTimeout(ctx context.Context) (map[string]int, error) { - requestCtx, cancel := context.WithTimeout(ctx, r.hip3Timeout) - defer cancel() - - offsets, err := r.fetchPerpDexOffsets(requestCtx) - if err == nil { - return offsets, nil - } - - if errors.Is(requestCtx.Err(), context.DeadlineExceeded) { - return nil, output.NewCLIError(output.ErrNetwork, "metadata request timed out"). - WithDetails("stage", "hip3_perp_dexs"). - WithDetails("type", "perpDexs"). - WithDetails("timeout_ms", r.hip3Timeout.Milliseconds()) - } - - return nil, wrapMetadataError(err, "hip3_perp_dexs", "perpDexs", "", r.hip3Timeout) -} - -func wrapMetadataError(err error, stage, metaType, dex string, timeout time.Duration) error { - var cliErr *output.CLIError - if errors.As(err, &cliErr) { - wrapped := output.NewCLIError(cliErr.Code, cliErr.Message). - WithDetails("stage", stage). - WithDetails("type", metaType). - WithDetails("dex", dex). - WithDetails("timeout_ms", timeout.Milliseconds()) - for k, v := range cliErr.Details { - wrapped = wrapped.WithDetails(k, v) - } - return wrapped - } - - return output.NewCLIError(output.ErrNetwork, "metadata request failed"). - WithDetails("stage", stage). - WithDetails("type", metaType). - WithDetails("dex", dex). - WithDetails("timeout_ms", timeout.Milliseconds()). - WithDetails("cause", err.Error()) -} - -func parseHIP3Dex(coin string) string { - idx := strings.Index(coin, ":") - if idx <= 0 { - return "" - } - return strings.ToLower(strings.TrimSpace(coin[:idx])) -} - -// buildMaps parses perp and spot metadata and populates the lookup maps. -func (r *CachingResolver) buildMaps(perpData, spotData []byte) error { - var pm perpMeta - if err := json.Unmarshal(perpData, &pm); err != nil { - return output.NewCLIError(output.ErrAPI, "failed to parse perp metadata"). - WithDetails("cause", err.Error()) - } - - var sm spotMeta - if err := json.Unmarshal(spotData, &sm); err != nil { - return output.NewCLIError(output.ErrAPI, "failed to parse spot metadata"). - WithDetails("cause", err.Error()) - } - - perpMap := make(map[string]*AssetInfo, len(pm.Universe)) - for i, asset := range pm.Universe { - upper := strings.ToUpper(asset.Name) - perpMap[upper] = &AssetInfo{ - AssetID: i, // perp asset ID = array index - Coin: asset.Name, - CanonicalCoin: asset.Name, - SzDecimals: asset.SzDecimals, - IsSpot: false, - } - } - - // Build lookup from token index → spotToken for resolving market references. - tokenByIndex := make(map[int]spotToken, len(sm.Tokens)) - for _, tok := range sm.Tokens { - tokenByIndex[tok.Index] = tok - } - - spotMap := make(map[string]*AssetInfo, len(sm.Universe)) - spotPairAliases := make(map[string][]spotPairCandidate, len(sm.Universe)) - for _, market := range sm.Universe { - if len(market.Tokens) == 0 { - continue - } - // The first element is the base token index of the spot pair. - baseIdx := market.Tokens[0] - token, ok := tokenByIndex[baseIdx] - if !ok { - return output.NewCLIError(output.ErrAPI, "spot market references unknown token index"). - WithDetails("market", market.Name). - WithDetails("tokenIndex", strconv.Itoa(baseIdx)) - } - info := &AssetInfo{ - AssetID: 10000 + market.Index, // spot asset ID = 10000 + spot market index - Coin: token.Name, - CanonicalCoin: market.Name, - SzDecimals: token.SzDecimals, - IsSpot: true, - } - // Allow resolution by both base token ("PURR") and full market name ("PURR/USDC"). - baseKey := strings.ToUpper(token.Name) - spotMap[baseKey] = info - marketKey := strings.ToUpper(market.Name) - spotMap[marketKey] = info - - if len(market.Tokens) < 2 { - continue - } - quoteIdx := market.Tokens[1] - quoteToken, ok := tokenByIndex[quoteIdx] - if !ok { - return output.NewCLIError(output.ErrAPI, "spot market references unknown quote token index"). - WithDetails("market", market.Name). - WithDetails("tokenIndex", strconv.Itoa(quoteIdx)) - } - - candidate := spotPairCandidate{ - info: info, - marketName: market.Name, - marketIndex: market.Index, - canonical: market.IsCanonical, - } - baseNames := append([]string{token.Name}, unitTokenAliases(token)...) - quoteNames := append([]string{quoteToken.Name}, unitTokenAliases(quoteToken)...) - - seenAliasKeys := make(map[string]struct{}, len(baseNames)*len(quoteNames)) - for _, baseName := range baseNames { - for _, quoteName := range quoteNames { - key := strings.ToUpper(baseName + "/" + quoteName) - if _, seen := seenAliasKeys[key]; seen { - continue - } - seenAliasKeys[key] = struct{}{} - spotPairAliases[key] = append(spotPairAliases[key], candidate) - } - } - } - - r.perpMap = perpMap - r.spotMap = spotMap - r.spotPairAliases = spotPairAliases - return nil -} - -func unitTokenAliases(token spotToken) []string { - fullName := strings.TrimSpace(token.FullName) - if !strings.HasPrefix(strings.ToUpper(fullName), "UNIT ") { - return nil - } - - name := strings.ToUpper(token.Name) - if len(name) < 2 || !strings.HasPrefix(name, "U") { - return nil - } - - aliases := make([]string, 0, len(name)-1) - seen := make(map[string]struct{}, len(name)-1) - for stripped := name; len(stripped) > 1 && strings.HasPrefix(stripped, "U"); { - stripped = stripped[1:] - if _, ok := seen[stripped]; ok { - continue - } - seen[stripped] = struct{}{} - aliases = append(aliases, stripped) - } - - return aliases -} - -func resolveSpotPairAlias(coin string, candidates []spotPairCandidate) (*AssetInfo, error) { - if len(candidates) == 1 { - return candidates[0].info, nil - } - - var canonical spotPairCandidate - canonicalCount := 0 - for _, candidate := range candidates { - if candidate.canonical { - canonical = candidate - canonicalCount++ - } - } - if canonicalCount == 1 { - return canonical.info, nil - } - - candidateMarkets := make([]string, 0, len(candidates)) - candidateHints := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - candidateMarkets = append(candidateMarkets, candidate.marketName) - candidateHints = append(candidateHints, candidate.marketName+" (market index "+strconv.Itoa(candidate.marketIndex)+")") - } - sort.Strings(candidateMarkets) - sort.Strings(candidateHints) - - return nil, output.NewCLIError(output.ErrValidation, "ambiguous spot pair: "+coin). - WithDetails("coin", coin). - WithDetails("reason", "ambiguous_spot_pair"). - WithDetails("candidates", candidateMarkets). - WithDetails("hint", "use an explicit spot market symbol from spot metadata (e.g. "+candidateHints[0]+")") -} - -// fetchPerpDexOffsets returns dex name to asset offset mapping. -// Per the official Python SDK, builder-deployed dexes are offset by: -// 110000 + i*10000, where i is the position in perpDexs()[1:]. -func (r *CachingResolver) fetchPerpDexOffsets(ctx context.Context) (map[string]int, error) { - raw, err := r.client.PostInfo(ctx, map[string]string{"type": "perpDexs"}) - if err != nil { - return nil, err - } - - var entries []json.RawMessage - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, err - } - - offsets := make(map[string]int) - for i, entry := range entries { - // Index 0 is reserved for the validator perp dex. - if i == 0 { - continue - } - if string(entry) == "null" { - continue - } - - var d perpDex - if err := json.Unmarshal(entry, &d); err != nil { - continue - } - if d.Name == "" { - continue - } - - offsets[strings.ToLower(d.Name)] = 110000 + (i-1)*10000 - } - - return offsets, nil -} diff --git a/pkg/resolver/spot_maps.go b/pkg/resolver/spot_maps.go new file mode 100644 index 0000000..ca9dec5 --- /dev/null +++ b/pkg/resolver/spot_maps.go @@ -0,0 +1,233 @@ +package resolver + +import ( + "context" + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/timbrinded/hlgo/pkg/output" +) + +type spotPairCandidate struct { + info *AssetInfo + marketName string + marketIndex int + canonical bool +} + +type perpDex struct { + Name string `json:"name"` +} + +// spotMeta is the response shape from POST /info {"type":"spotMeta"}. +// The real API returns a two-level structure: a flat top-level "tokens" registry +// and universe entries that reference tokens by integer index. +type spotMeta struct { + Universe []spotMarket `json:"universe"` + Tokens []spotToken `json:"tokens"` // top-level token registry +} + +// spotMarket is a single spot market in the spot universe array. +type spotMarket struct { + Name string `json:"name"` + Index int `json:"index"` + Tokens []int `json:"tokens"` // indices into spotMeta.Tokens + IsCanonical bool `json:"isCanonical"` // optional on some API responses +} + +// spotToken is a token within a spot market. +type spotToken struct { + Name string `json:"name"` + Index int `json:"index"` + SzDecimals int `json:"szDecimals"` + FullName string `json:"fullName"` +} + +// buildMaps parses perp and spot metadata and populates the lookup maps. +func (r *CachingResolver) buildMaps(perpData, spotData []byte) error { + var perpMetadata perpMeta + if err := json.Unmarshal(perpData, &perpMetadata); err != nil { + return output.NewCLIError(output.ErrAPI, "failed to parse perp metadata"). + WithDetails("cause", err.Error()) + } + + var spotMetadata spotMeta + if err := json.Unmarshal(spotData, &spotMetadata); err != nil { + return output.NewCLIError(output.ErrAPI, "failed to parse spot metadata"). + WithDetails("cause", err.Error()) + } + + perpMap := make(map[string]*AssetInfo, len(perpMetadata.Universe)) + for index, asset := range perpMetadata.Universe { + upper := strings.ToUpper(asset.Name) + perpMap[upper] = &AssetInfo{ + AssetID: index, + Coin: asset.Name, + CanonicalCoin: asset.Name, + SzDecimals: asset.SzDecimals, + IsSpot: false, + } + } + + // Build lookup from token index → spotToken for resolving market references. + tokenByIndex := make(map[int]spotToken, len(spotMetadata.Tokens)) + for _, token := range spotMetadata.Tokens { + tokenByIndex[token.Index] = token + } + + spotMap, spotPairAliases, err := buildSpotMaps(spotMetadata.Universe, tokenByIndex) + if err != nil { + return err + } + + r.perpMap = perpMap + r.spotMap = spotMap + r.spotPairAliases = spotPairAliases + return nil +} + +func buildSpotMaps(markets []spotMarket, tokenByIndex map[int]spotToken) (map[string]*AssetInfo, map[string][]spotPairCandidate, error) { + spotMap := make(map[string]*AssetInfo, len(markets)) + spotPairAliases := make(map[string][]spotPairCandidate, len(markets)) + for _, market := range markets { + if len(market.Tokens) == 0 { + continue + } + baseIdx := market.Tokens[0] + token, ok := tokenByIndex[baseIdx] + if !ok { + return nil, nil, output.NewCLIError(output.ErrAPI, "spot market references unknown token index"). + WithDetails("market", market.Name). + WithDetails("tokenIndex", strconv.Itoa(baseIdx)) + } + + info := &AssetInfo{AssetID: 10000 + market.Index, Coin: token.Name, CanonicalCoin: market.Name, SzDecimals: token.SzDecimals, IsSpot: true} + spotMap[strings.ToUpper(token.Name)] = info + spotMap[strings.ToUpper(market.Name)] = info + if len(market.Tokens) < 2 { + continue + } + + quoteIdx := market.Tokens[1] + quoteToken, ok := tokenByIndex[quoteIdx] + if !ok { + return nil, nil, output.NewCLIError(output.ErrAPI, "spot market references unknown quote token index"). + WithDetails("market", market.Name). + WithDetails("tokenIndex", strconv.Itoa(quoteIdx)) + } + + candidate := spotPairCandidate{info: info, marketName: market.Name, marketIndex: market.Index, canonical: market.IsCanonical} + baseNames := append([]string{token.Name}, unitTokenAliases(token)...) + quoteNames := append([]string{quoteToken.Name}, unitTokenAliases(quoteToken)...) + seenAliasKeys := make(map[string]struct{}, len(baseNames)*len(quoteNames)) + for _, baseName := range baseNames { + for _, quoteName := range quoteNames { + key := strings.ToUpper(baseName + "/" + quoteName) + if _, seen := seenAliasKeys[key]; seen { + continue + } + seenAliasKeys[key] = struct{}{} + spotPairAliases[key] = append(spotPairAliases[key], candidate) + } + } + } + return spotMap, spotPairAliases, nil +} + +func unitTokenAliases(token spotToken) []string { + fullName := strings.TrimSpace(token.FullName) + if !strings.HasPrefix(strings.ToUpper(fullName), "UNIT ") { + return nil + } + + name := strings.ToUpper(token.Name) + if len(name) < 2 || !strings.HasPrefix(name, "U") { + return nil + } + + aliases := make([]string, 0, len(name)-1) + seen := make(map[string]struct{}, len(name)-1) + for stripped := name; len(stripped) > 1 && strings.HasPrefix(stripped, "U"); { + stripped = stripped[1:] + if _, ok := seen[stripped]; ok { + continue + } + seen[stripped] = struct{}{} + aliases = append(aliases, stripped) + } + + return aliases +} + +func resolveSpotPairAlias(coin string, candidates []spotPairCandidate) (*AssetInfo, error) { + if len(candidates) == 1 { + return candidates[0].info, nil + } + + var canonical spotPairCandidate + canonicalCount := 0 + for _, candidate := range candidates { + if candidate.canonical { + canonical = candidate + canonicalCount++ + } + } + if canonicalCount == 1 { + return canonical.info, nil + } + + candidateMarkets := make([]string, 0, len(candidates)) + candidateHints := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + candidateMarkets = append(candidateMarkets, candidate.marketName) + candidateHints = append(candidateHints, candidate.marketName+" (market index "+strconv.Itoa(candidate.marketIndex)+")") + } + sort.Strings(candidateMarkets) + sort.Strings(candidateHints) + + return nil, output.NewCLIError(output.ErrValidation, "ambiguous spot pair: "+coin). + WithDetails("coin", coin). + WithDetails("reason", "ambiguous_spot_pair"). + WithDetails("candidates", candidateMarkets). + WithDetails("hint", "use an explicit spot market symbol from spot metadata (e.g. "+candidateHints[0]+")") +} + +// fetchPerpDexOffsets returns dex name to asset offset mapping. +// Per the official Python SDK, builder-deployed dexes are offset by: +// 110000 + i*10000, where i is the position in perpDexs()[1:]. +func (r *CachingResolver) fetchPerpDexOffsets(ctx context.Context) (map[string]int, error) { + raw, err := r.client.PostInfo(ctx, map[string]string{"type": "perpDexs"}) + if err != nil { + return nil, err + } + + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, err + } + + offsets := make(map[string]int) + for i, entry := range entries { + // Index 0 is reserved for the validator perp dex. + if i == 0 { + continue + } + if string(entry) == "null" { + continue + } + + var dex perpDex + if err := json.Unmarshal(entry, &dex); err != nil { + continue + } + if dex.Name == "" { + continue + } + + offsets[strings.ToLower(dex.Name)] = 110000 + (i-1)*10000 + } + + return offsets, nil +} diff --git a/pkg/signer/eip712.go b/pkg/signer/eip712.go index 8365129..397e41a 100644 --- a/pkg/signer/eip712.go +++ b/pkg/signer/eip712.go @@ -30,23 +30,13 @@ var eip712DomainType = []apitypes.Type{ // l1Domain returns the EIP-712 domain for L1 phantom agent signing. // name="Exchange", version="1", chainId=1337, verifyingContract=0x000...000. func l1Domain() apitypes.TypedDataDomain { - return apitypes.TypedDataDomain{ - Name: "Exchange", - Version: "1", - ChainId: math.NewHexOrDecimal256(chainIDL1), - VerifyingContract: zeroAddress, - } + return apitypes.TypedDataDomain{Name: "Exchange", Version: "1", ChainId: math.NewHexOrDecimal256(chainIDL1), VerifyingContract: zeroAddress} } // userActionDomain returns the EIP-712 domain for user-signed actions. // name="HyperliquidSignTransaction", version="1", chainId=421614, verifyingContract=0x000...000. func userActionDomain() apitypes.TypedDataDomain { - return apitypes.TypedDataDomain{ - Name: "HyperliquidSignTransaction", - Version: "1", - ChainId: math.NewHexOrDecimal256(chainIDUserSigned), - VerifyingContract: zeroAddress, - } + return apitypes.TypedDataDomain{Name: "HyperliquidSignTransaction", Version: "1", ChainId: math.NewHexOrDecimal256(chainIDUserSigned), VerifyingContract: zeroAddress} } // hyperliquidChain returns the chain name string for EIP-712 message content. @@ -66,13 +56,5 @@ func userActionTypedData(typeName string, typeFields []apitypes.Type, message ma maps.Copy(msg, message) msg["hyperliquidChain"] = hyperliquidChain(isMainnet) - return apitypes.TypedData{ - Types: apitypes.Types{ - "EIP712Domain": eip712DomainType, - typeName: typeFields, - }, - PrimaryType: typeName, - Domain: userActionDomain(), - Message: msg, - } + return apitypes.TypedData{Types: apitypes.Types{"EIP712Domain": eip712DomainType, typeName: typeFields}, PrimaryType: typeName, Domain: userActionDomain(), Message: msg} } diff --git a/pkg/signer/phantom.go b/pkg/signer/phantom.go index 797d117..1aa57bf 100644 --- a/pkg/signer/phantom.go +++ b/pkg/signer/phantom.go @@ -60,16 +60,5 @@ func buildConnectionID(action any, nonce int64, vaultAddress *common.Address, ex // phantomAgentTypedData builds the EIP-712 typed data for a phantom agent signing request. func phantomAgentTypedData(source string, connectionID common.Hash) apitypes.TypedData { - return apitypes.TypedData{ - Types: apitypes.Types{ - "EIP712Domain": eip712DomainType, - "Agent": phantomAgentType, - }, - PrimaryType: "Agent", - Domain: l1Domain(), - Message: apitypes.TypedDataMessage{ - "source": source, - "connectionId": connectionID[:], - }, - } + return apitypes.TypedData{Types: apitypes.Types{"EIP712Domain": eip712DomainType, "Agent": phantomAgentType}, PrimaryType: "Agent", Domain: l1Domain(), Message: apitypes.TypedDataMessage{"source": source, "connectionId": connectionID[:]}} } diff --git a/pkg/signer/signer.go b/pkg/signer/signer.go index 251cb3a..ac6192e 100644 --- a/pkg/signer/signer.go +++ b/pkg/signer/signer.go @@ -63,10 +63,7 @@ func NewSigner(privateKeyHex string) (*LocalSigner, error) { address := crypto.PubkeyToAddress(key.PublicKey) - return &LocalSigner{ - key: key, - address: address, - }, nil + return &LocalSigner{key: key, address: address}, nil } // Address returns the Ethereum address derived from the private key. From bca60344769e3d9e6f4ee4870c2ccd43e7293bcd Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:22:10 +0000 Subject: [PATCH 2/3] refactor: simplify added request and status flows --- cmd/info_user.go | 8 ++-- pkg/client/client.go | 19 ++------ pkg/client/client_test.go | 56 +++++++++-------------- pkg/client/exchange_response.go | 66 ++++++++++++--------------- pkg/exchange/executor_management.go | 3 +- pkg/exchange/executor_user.go | 16 +------ pkg/exchange/executor_user_helpers.go | 3 +- 7 files changed, 65 insertions(+), 106 deletions(-) diff --git a/cmd/info_user.go b/cmd/info_user.go index 56f51fa..587b04a 100644 --- a/cmd/info_user.go +++ b/cmd/info_user.go @@ -128,7 +128,7 @@ func newInfoFillsCmd() *cobra.Command { return printResult(cmd, cfg, mustMarshal(request), nil) } - raw, err := fetchUserFillsRaw(cmd.Context(), buildInfoClient(cfg), user, request) + raw, err := fetchUserFillsRaw(cmd.Context(), buildInfoClient(cfg), request) if err != nil { return err } @@ -146,11 +146,11 @@ func newInfoFillsCmd() *cobra.Command { return cmd } -func fetchUserFillsRaw(ctx context.Context, ic *info.InfoClient, user string, request info.UserFillsRequest) ([]byte, error) { +func fetchUserFillsRaw(ctx context.Context, ic *info.InfoClient, request info.UserFillsRequest) ([]byte, error) { if request.Type == "userFillsByTime" { - return ic.UserFillsByTime(ctx, user, request.StartTime, request.EndTime, request.AggregateByTime) + return ic.UserFillsByTime(ctx, request.User, request.StartTime, request.EndTime, request.AggregateByTime) } - return ic.UserFills(ctx, user, request.AggregateByTime) + return ic.UserFills(ctx, request.User, request.AggregateByTime) } func newInfoOrderStatusCmd() *cobra.Command { diff --git a/pkg/client/client.go b/pkg/client/client.go index 2ca2a13..3587f05 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -62,8 +62,8 @@ type SignatureWire struct { V int `json:"v"` } -// exchangeRequest is the envelope for POST /exchange. -type exchangeRequest struct { +// ExchangeRequest is the signed request envelope for POST /exchange. +type ExchangeRequest struct { Action any `json:"action"` Nonce int64 `json:"nonce"` Signature SignatureWire `json:"signature"` @@ -78,17 +78,8 @@ func (c *Client) PostInfo(ctx context.Context, request any) (json.RawMessage, er } // PostExchange sends a signed action to the /exchange endpoint. -// The action, nonce, and signature are wrapped in the standard envelope. -// vaultAddress is included only when non-empty. -func (c *Client) PostExchange(ctx context.Context, action any, nonce int64, signature SignatureWire, vaultAddress string, expiresAfter *int64) (json.RawMessage, error) { - body := exchangeRequest{ - Action: action, - Nonce: nonce, - Signature: signature, - VaultAddress: vaultAddress, - ExpiresAfter: expiresAfter, - } - raw, err := c.doPost(ctx, "/exchange", body) +func (c *Client) PostExchange(ctx context.Context, request ExchangeRequest) (json.RawMessage, error) { + raw, err := c.doPost(ctx, "/exchange", request) if err != nil { return nil, err } @@ -242,7 +233,7 @@ func (c *Client) recordWeight(path string, payload []byte) { c.weightTracker.Record(weight) } - if c.warnWriter != nil && c.weightTracker.ShouldWarn() { + if c.warnWriter != nil { if warning := c.weightTracker.WarningJSON(); warning != nil { //nolint:errcheck // best-effort warning; stderr write failure is non-fatal c.warnWriter.Write(append(warning, '\n')) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index a293b42..914980e 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -104,7 +104,7 @@ func TestPostExchange_Success(t *testing.T) { c := NewClient(srv.URL) action := map[string]any{"type": "order", "orders": []any{}} - result, err := c.PostExchange(context.Background(), action, 1700000000000, SignatureWire{R: "0xdead", S: "0xbeef", V: 27}, "0xvault", &expiresAfter) + result, err := c.PostExchange(context.Background(), ExchangeRequest{Action: action, Nonce: 1700000000000, Signature: SignatureWire{R: "0xdead", S: "0xbeef", V: 27}, VaultAddress: "0xvault", ExpiresAfter: &expiresAfter}) if err != nil { t.Fatalf("PostExchange returned error: %v", err) } @@ -133,7 +133,7 @@ func TestPostExchange_OmitsEmptyVaultAddress(t *testing.T) { defer srv.Close() c := NewClient(srv.URL) - _, err := c.PostExchange(context.Background(), map[string]string{"type": "cancel"}, 1700000000000, SignatureWire{R: "0x1", S: "0x2", V: 27}, "", nil) + _, err := c.PostExchange(context.Background(), ExchangeRequest{Action: map[string]string{"type": "cancel"}, Nonce: 1700000000000, Signature: SignatureWire{R: "0x1", S: "0x2", V: 27}}) if err != nil { t.Fatalf("PostExchange returned error: %v", err) } @@ -147,14 +147,11 @@ func TestPostExchange_StatusErrReturnsAPIError(t *testing.T) { defer srv.Close() c := NewClient(srv.URL) - _, err := c.PostExchange( - context.Background(), - map[string]string{"type": "approveAgent"}, - 1700000000000, - SignatureWire{R: "0x1", S: "0x2", V: 27}, - "", - nil, - ) + _, err := c.PostExchange(context.Background(), ExchangeRequest{ + Action: map[string]string{"type": "approveAgent"}, + Nonce: 1700000000000, + Signature: SignatureWire{R: "0x1", S: "0x2", V: 27}, + }) if err == nil { t.Fatal("expected error for exchange status=err") } @@ -188,14 +185,11 @@ func TestPostExchange_OrderStatusesErrorReturnsAPIError(t *testing.T) { defer srv.Close() c := NewClient(srv.URL) - _, err := c.PostExchange( - context.Background(), - map[string]string{"type": "order"}, - 1700000000000, - SignatureWire{R: "0x1", S: "0x2", V: 27}, - "", - nil, - ) + _, err := c.PostExchange(context.Background(), ExchangeRequest{ + Action: map[string]string{"type": "order"}, + Nonce: 1700000000000, + Signature: SignatureWire{R: "0x1", S: "0x2", V: 27}, + }) if err == nil { t.Fatal("expected error for exchange data.statuses[].error") } @@ -231,14 +225,11 @@ func TestPostExchange_WaitingForFillStatusesAreAccepted(t *testing.T) { defer srv.Close() c := NewClient(srv.URL) - result, err := c.PostExchange( - context.Background(), - map[string]string{"type": "order"}, - 1700000000000, - SignatureWire{R: "0x1", S: "0x2", V: 27}, - "", - nil, - ) + result, err := c.PostExchange(context.Background(), ExchangeRequest{ + Action: map[string]string{"type": "order"}, + Nonce: 1700000000000, + Signature: SignatureWire{R: "0x1", S: "0x2", V: 27}, + }) if err != nil { t.Fatalf("expected waitingForFill statuses to be treated as benign, got error: %v", err) } @@ -255,14 +246,11 @@ func TestPostExchange_MixedOrderStatusesDetectsError(t *testing.T) { defer srv.Close() c := NewClient(srv.URL) - _, err := c.PostExchange( - context.Background(), - map[string]string{"type": "cancel"}, - 1700000000000, - SignatureWire{R: "0x1", S: "0x2", V: 27}, - "", - nil, - ) + _, err := c.PostExchange(context.Background(), ExchangeRequest{ + Action: map[string]string{"type": "cancel"}, + Nonce: 1700000000000, + Signature: SignatureWire{R: "0x1", S: "0x2", V: 27}, + }) if err == nil { t.Fatal("expected error for mixed statuses containing an error entry") } diff --git a/pkg/client/exchange_response.go b/pkg/client/exchange_response.go index 3140321..c1d0b3c 100644 --- a/pkg/client/exchange_response.go +++ b/pkg/client/exchange_response.go @@ -58,51 +58,16 @@ func validateExchangeStatuses(status string, response json.RawMessage) error { Statuses []json.RawMessage `json:"statuses"` } `json:"data"` } - if err := json.Unmarshal(response, &payload); err != nil { - return nil - } - if len(payload.Data.Statuses) == 0 { + if err := json.Unmarshal(response, &payload); err != nil || len(payload.Data.Statuses) == 0 { return nil } var errs []string for _, entryRaw := range payload.Data.Statuses { - var asString string - if err := json.Unmarshal(entryRaw, &asString); err == nil { - asString = strings.TrimSpace(asString) - if isBenignExchangeStatus(asString) { - continue - } - errs = append(errs, asString) - continue - } - - var asObject map[string]json.RawMessage - if err := json.Unmarshal(entryRaw, &asObject); err != nil { - continue - } - - rawErr, ok := asObject["error"] - if !ok { - continue - } - - var msg string - if err := json.Unmarshal(rawErr, &msg); err == nil && strings.TrimSpace(msg) != "" { - if isBenignExchangeStatus(msg) { - continue - } + if msg := exchangeStatusError(entryRaw); msg != "" { errs = append(errs, msg) - continue } - - rawErrText := strings.TrimSpace(string(rawErr)) - if isBenignExchangeStatus(rawErrText) { - continue - } - errs = append(errs, rawErrText) } - if len(errs) == 0 { return nil } @@ -112,3 +77,30 @@ func validateExchangeStatuses(status string, response json.RawMessage) error { WithDetails("exchange_status", status). WithDetails("exchange_errors", errs) } + +func exchangeStatusError(entryRaw json.RawMessage) string { + var msg string + if err := json.Unmarshal(entryRaw, &msg); err == nil { + msg = strings.TrimSpace(msg) + if isBenignExchangeStatus(msg) { + return "" + } + return msg + } + + var entry struct { + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(entryRaw, &entry); err != nil || len(entry.Error) == 0 { + return "" + } + + if err := json.Unmarshal(entry.Error, &msg); err != nil { + msg = string(entry.Error) + } + msg = strings.TrimSpace(msg) + if isBenignExchangeStatus(msg) { + return "" + } + return msg +} diff --git a/pkg/exchange/executor_management.go b/pkg/exchange/executor_management.go index 55ea66c..43ffba2 100644 --- a/pkg/exchange/executor_management.go +++ b/pkg/exchange/executor_management.go @@ -8,6 +8,7 @@ import ( "github.com/shopspring/decimal" + "github.com/timbrinded/hlgo/pkg/client" "github.com/timbrinded/hlgo/pkg/output" "github.com/timbrinded/hlgo/pkg/wire" ) @@ -172,7 +173,7 @@ func (e *Executor) executeL1Action(ctx context.Context, action any, expiresAfter if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) + return e.client.PostExchange(ctx, client.ExchangeRequest{Action: action, Nonce: nonce, Signature: sigToWire(sig), ExpiresAfter: expiresAfter}) } // PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. diff --git a/pkg/exchange/executor_user.go b/pkg/exchange/executor_user.go index e4fe68c..728b448 100644 --- a/pkg/exchange/executor_user.go +++ b/pkg/exchange/executor_user.go @@ -145,21 +145,7 @@ func (e *Executor) Withdraw3(ctx context.Context, input Withdraw3Input) (json.Ra // ClassTransfer executes a classTransfer user-signed action. func (e *Executor) ClassTransfer(ctx context.Context, input ClassTransferInput) (json.RawMessage, error) { - if !input.Amount.IsPositive() { - return nil, output.NewCLIError(output.ErrValidation, "amount must be positive"). - WithDetails("value", input.Amount.String()) - } - - nonce := time.Now().UnixMilli() - action := BuildUSDClassTransferAction(input.Amount.String(), input.ToPerp, nonce) - return e.executeUserAction( - ctx, - action, - nonce, - "HyperliquidTransaction:UsdClassTransfer", - usdClassTransferSignTypes, - input.DryRun, - ) + return e.USDClassTransfer(ctx, USDClassTransferInput(input)) } // SpotSend executes a spotSend user-signed action. diff --git a/pkg/exchange/executor_user_helpers.go b/pkg/exchange/executor_user_helpers.go index c05b3c3..1bb178f 100644 --- a/pkg/exchange/executor_user_helpers.go +++ b/pkg/exchange/executor_user_helpers.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/timbrinded/hlgo/pkg/client" "github.com/timbrinded/hlgo/pkg/output" ) @@ -69,7 +70,7 @@ func (e *Executor) executeUserAction( return nil, err } - return e.client.PostExchange(ctx, actionMap, nonce, sigToWire(sig), "", nil) + return e.client.PostExchange(ctx, client.ExchangeRequest{Action: actionMap, Nonce: nonce, Signature: sigToWire(sig)}) } func userActionMap(action any) (map[string]any, error) { From 85db866a0b8da1e007ec43bc0ec431fd3e1e0d84 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:10:38 +0000 Subject: [PATCH 3/3] refactor --- .github/workflows/integration.yml | 4 +- Makefile | 24 +++- cmd/account_approve_agent.go | 12 +- cmd/account_test.go | 23 ++++ e2e/agent_simulation_test.go | 6 +- e2e/e2e_test.go | 18 ++- e2e/integration_cli_test.go | 218 ++++++++++++++++++++++++------ 7 files changed, 247 insertions(+), 58 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d97e511..8f67a72 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -3,6 +3,7 @@ name: Integration on: push: branches: [main] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -51,6 +52,5 @@ jobs: - name: Integration tests env: HL_TESTNET: "true" - HL_TEST_AGENT_KEY: ${{ secrets.HL_TEST_AGENT_KEY }} - HL_TEST_MASTER_KEY: ${{ secrets.HL_TEST_MASTER_KEY }} + HL_TEST_PRIVATE_KEY: ${{ secrets.HL_TEST_MASTER_KEY }} run: make test-integration diff --git a/Makefile b/Makefile index 7f6d660..32f2ca6 100644 --- a/Makefile +++ b/Makefile @@ -10,13 +10,13 @@ BIN_DIR := bin DIST_DIR := dist TEST_OUT := /tmp/hlgo-test.out -.PHONY: build test test\:ci test-cover test-integration vet fmt lint tidy check clean install \ +.PHONY: build test test\:ci test-cover test-integration test-integration-live vet fmt lint tidy check clean install \ build-linux build-darwin build-windows dist -# run-tests-quiet: local-friendly test runner — shows only failures + summary. -# Usage: $(call run-tests-quiet,) -define run-tests-quiet - @go test -race -count=1 $(1) ./... 2>&1 | tee $(TEST_OUT); \ +# run-tests-quiet-pkgs: local-friendly test runner — shows only failures + summary. +# Usage: $(call run-tests-quiet-pkgs,,) +define run-tests-quiet-pkgs + @go test -race -count=1 $(1) $(2) 2>&1 | tee $(TEST_OUT); \ status=$${PIPESTATUS[0]}; \ echo ""; \ echo "──────────────────────────────────────────"; \ @@ -27,6 +27,12 @@ define run-tests-quiet exit $$status endef +# run-tests-quiet: local-friendly test runner — shows only failures + summary. +# Usage: $(call run-tests-quiet,) +define run-tests-quiet + $(call run-tests-quiet-pkgs,$(1),./...) +endef + # run-tests-verbose: CI-friendly — full verbose output + summary. # Usage: $(call run-tests-verbose,) define run-tests-verbose @@ -59,9 +65,13 @@ test-cover: @go tool cover -html=coverage.out -o coverage.html @echo "Coverage report: coverage.html" -## test-integration: run integration tests (requires testnet env vars) +## test-integration: run safe integration tests (live account mutation tests are excluded) test-integration: - $(call run-tests-quiet,-tags=integration -timeout=5m) + $(call run-tests-quiet-pkgs,-tags=integration -timeout=5m -skip '^TestIntegration_AccountLive',./e2e) + +## test-integration-live: run destructive live account integration tests (requires HL_TEST_PRIVATE_KEY + HL_TEST_LIVE_CONFIG_JSON) +test-integration-live: + $(call run-tests-quiet-pkgs,-tags=integration -timeout=5m -run '^TestIntegration_AccountLive',./e2e) ## vet: static analysis vet: diff --git a/cmd/account_approve_agent.go b/cmd/account_approve_agent.go index 3cd3f93..0076384 100644 --- a/cmd/account_approve_agent.go +++ b/cmd/account_approve_agent.go @@ -3,6 +3,7 @@ package cmd import ( "strings" + "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -23,6 +24,11 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag + if !common.IsHexAddress(agent) { + return output.NewCLIError(output.ErrValidation, "invalid agent address"). + WithDetails("agent", agent) + } + agentName := strings.TrimSpace(name) switch { case revoke: @@ -44,7 +50,11 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, return err } - input := exchange.ApproveAgentInput{AgentAddress: agent, AgentName: agentName, DryRun: cfg.DryRun} + input := exchange.ApproveAgentInput{ + AgentAddress: strings.ToLower(agent), + AgentName: agentName, + DryRun: cfg.DryRun, + } raw, err := exec.ApproveAgent(cmd.Context(), input) if err != nil { return err diff --git a/cmd/account_test.go b/cmd/account_test.go index 91842e9..e0a1a7a 100644 --- a/cmd/account_test.go +++ b/cmd/account_test.go @@ -191,6 +191,29 @@ func TestAccountApproveAgent_NameRequiredWithoutRevoke(t *testing.T) { } } +func TestAccountApproveAgent_InvalidAddressPrecedesNameValidation(t *testing.T) { + _, _, run := newTestRootWithServer(t, "") + + err := run("account", "approve-agent", + "--agent", "bad", + "--dry-run", + ) + if err == nil { + t.Fatal("expected validation error for invalid agent address") + } + + var cliErr *output.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected CLIError, got %T", err) + } + if cliErr.Code != output.ErrValidation { + t.Fatalf("code = %q, want %q", cliErr.Code, output.ErrValidation) + } + if cliErr.Message != "invalid agent address" { + t.Fatalf("message = %q, want %q", cliErr.Message, "invalid agent address") + } +} + func TestAccountSetAbstraction_DryRun(t *testing.T) { stdout, _, run := newTestRootWithServer(t, "") diff --git a/e2e/agent_simulation_test.go b/e2e/agent_simulation_test.go index b0ef332..8700428 100644 --- a/e2e/agent_simulation_test.go +++ b/e2e/agent_simulation_test.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "os" "strings" "testing" "time" @@ -19,13 +18,10 @@ import ( const agentSimulationTimeout = 3 * time.Minute func TestE2E_AgentSimulation(t *testing.T) { - privateKey := strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) - if privateKey == "" { + if fundedE2EPrivateKey() == "" { t.Skip("skipping agent simulation: set HL_TEST_PRIVATE_KEY") } - t.Setenv("HL_PRIVATE_KEY", privateKey) - start := time.Now() assertWithinTimeout := func(step string) { t.Helper() diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 4af1854..f18d8f4 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,6 +18,17 @@ var binaryPath string const e2eTestPrivateKey = "0x0123456789012345678901234567890123456789012345678901234567890123" +func fundedE2EPrivateKey() string { + return strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) +} + +func effectiveE2EPrivateKey() string { + if key := fundedE2EPrivateKey(); key != "" { + return key + } + return e2eTestPrivateKey +} + func TestMain(m *testing.M) { // Build the binary once for all tests. dir, err := os.MkdirTemp("", "hlgo-e2e") @@ -34,13 +45,12 @@ func TestMain(m *testing.M) { panic("cannot build hlgo: " + err.Error()) } - // Isolate E2E runs from user-local config and ensure dry-run order tests have a key. + // Isolate E2E runs from user-local config and ensure all CLI subprocesses use + // the same resolved key. Funded runs opt in via HL_TEST_PRIVATE_KEY. if os.Getenv("HL_CONFIG") == "" { _ = os.Setenv("HL_CONFIG", filepath.Join(dir, "e2e-config.yaml")) } - if os.Getenv("HL_PRIVATE_KEY") == "" { - _ = os.Setenv("HL_PRIVATE_KEY", e2eTestPrivateKey) - } + _ = os.Setenv("HL_PRIVATE_KEY", effectiveE2EPrivateKey()) os.Exit(m.Run()) } diff --git a/e2e/integration_cli_test.go b/e2e/integration_cli_test.go index fc047ea..397cb78 100644 --- a/e2e/integration_cli_test.go +++ b/e2e/integration_cli_test.go @@ -17,12 +17,38 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" ethcrypto "github.com/ethereum/go-ethereum/crypto" ) var integrationBinaryPath string const integrationTestPrivateKey = "0x0123456789012345678901234567890123456789012345678901234567890123" +const integrationLiveConfigEnv = "HL_TEST_LIVE_CONFIG_JSON" + +type integrationLiveConfig struct { + TransferAmount string `json:"transfer_amount"` + ApproveAgentAddress string `json:"approve_agent_address"` + Withdraw integrationLiveWithdrawConfig `json:"withdraw"` + SendAsset integrationLiveSendAssetConfig `json:"send_asset"` + SetAbstraction integrationLiveSetAbstractionConfig `json:"set_abstraction"` +} + +type integrationLiveWithdrawConfig struct { + Destination string `json:"destination"` + Amount string `json:"amount"` +} + +type integrationLiveSendAssetConfig struct { + Destination string `json:"destination"` + Token string `json:"token"` + Amount string `json:"amount"` +} + +type integrationLiveSetAbstractionConfig struct { + User string `json:"user"` + Value string `json:"value"` +} func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "hlgo-integration") @@ -309,25 +335,106 @@ func TestIntegration_InfoLookupAllDexesAllowsInheritedDefaultDex(t *testing.T) { requireFieldString(t, hip3Req, "dex", "") } -func requiredLiveEnv(t *testing.T, names ...string) map[string]string { +func parseIntegrationLiveConfig(raw string) (integrationLiveConfig, error) { + var cfg integrationLiveConfig + if strings.TrimSpace(raw) == "" { + return cfg, fmt.Errorf("%s is empty", integrationLiveConfigEnv) + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, fmt.Errorf("invalid JSON: %w", err) + } + + cfg.TransferAmount = strings.TrimSpace(cfg.TransferAmount) + cfg.ApproveAgentAddress = strings.TrimSpace(cfg.ApproveAgentAddress) + cfg.Withdraw.Destination = strings.TrimSpace(cfg.Withdraw.Destination) + cfg.Withdraw.Amount = strings.TrimSpace(cfg.Withdraw.Amount) + cfg.SendAsset.Destination = strings.TrimSpace(cfg.SendAsset.Destination) + cfg.SendAsset.Token = strings.TrimSpace(cfg.SendAsset.Token) + cfg.SendAsset.Amount = strings.TrimSpace(cfg.SendAsset.Amount) + cfg.SetAbstraction.User = strings.TrimSpace(cfg.SetAbstraction.User) + cfg.SetAbstraction.Value = strings.TrimSpace(cfg.SetAbstraction.Value) + + if cfg.ApproveAgentAddress != "" && !common.IsHexAddress(cfg.ApproveAgentAddress) { + return cfg, fmt.Errorf("approve_agent_address must be a hex address") + } + + return cfg, nil +} + +func loadIntegrationLiveConfig(t *testing.T) integrationLiveConfig { t.Helper() - vals := make(map[string]string, len(names)) + raw := strings.TrimSpace(os.Getenv(integrationLiveConfigEnv)) + if raw == "" { + t.Fatalf("live account integration requires %s", integrationLiveConfigEnv) + } + + cfg, err := parseIntegrationLiveConfig(raw) + if err != nil { + t.Fatalf("invalid %s: %v", integrationLiveConfigEnv, err) + } + return cfg +} + +func (cfg integrationLiveConfig) missingReversibleFields() []string { var missing []string - for _, name := range names { - val := strings.TrimSpace(os.Getenv(name)) - if val == "" { - missing = append(missing, name) - continue - } - vals[name] = val + if cfg.TransferAmount == "" { + missing = append(missing, "transfer_amount") + } + return missing +} + +func (cfg integrationLiveConfig) missingOneWayFields() []string { + var missing []string + if cfg.Withdraw.Destination == "" { + missing = append(missing, "withdraw.destination") + } + if cfg.Withdraw.Amount == "" { + missing = append(missing, "withdraw.amount") + } + if cfg.SendAsset.Destination == "" { + missing = append(missing, "send_asset.destination") + } + if cfg.SendAsset.Token == "" { + missing = append(missing, "send_asset.token") + } + if cfg.SendAsset.Amount == "" { + missing = append(missing, "send_asset.amount") + } + if cfg.SetAbstraction.User == "" { + missing = append(missing, "set_abstraction.user") + } + if cfg.SetAbstraction.Value == "" { + missing = append(missing, "set_abstraction.value") } + return missing +} - if len(missing) > 0 { - t.Fatalf("live account integration requires env vars: %s", strings.Join(missing, ", ")) +func requireIntegrationLivePrivateKey(t *testing.T) { + t.Helper() + if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { + t.Fatalf("live account integration requires HL_TEST_PRIVATE_KEY") } +} - return vals +func requireIntegrationLiveReversibleConfig(t *testing.T) integrationLiveConfig { + t.Helper() + + cfg := loadIntegrationLiveConfig(t) + if missing := cfg.missingReversibleFields(); len(missing) > 0 { + t.Fatalf("%s is missing required fields: %s", integrationLiveConfigEnv, strings.Join(missing, ", ")) + } + return cfg +} + +func requireIntegrationLiveOneWayConfig(t *testing.T) integrationLiveConfig { + t.Helper() + + cfg := loadIntegrationLiveConfig(t) + if missing := cfg.missingOneWayFields(); len(missing) > 0 { + t.Fatalf("%s is missing required fields: %s", integrationLiveConfigEnv, strings.Join(missing, ", ")) + } + return cfg } func newIntegrationCloid(t *testing.T) string { @@ -350,6 +457,53 @@ func newIntegrationAddress(t *testing.T) string { return "0x" + hex.EncodeToString(b[:]) } +func TestParseIntegrationLiveConfig_TrimsValues(t *testing.T) { + cfg, err := parseIntegrationLiveConfig(`{ + "transfer_amount": " 1.25 ", + "approve_agent_address": " 0x1111111111111111111111111111111111111111 ", + "withdraw": {"destination": " 0x2222222222222222222222222222222222222222 ", "amount": " 2 "}, + "send_asset": {"destination": " 0x3333333333333333333333333333333333333333 ", "token": " PURR:0x1 ", "amount": " 3 "}, + "set_abstraction": {"user": " 0x4444444444444444444444444444444444444444 ", "value": " disabled "} + }`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.TransferAmount != "1.25" { + t.Fatalf("transfer_amount = %q, want 1.25", cfg.TransferAmount) + } + if cfg.ApproveAgentAddress != "0x1111111111111111111111111111111111111111" { + t.Fatalf("approve_agent_address = %q", cfg.ApproveAgentAddress) + } + if cfg.Withdraw.Destination != "0x2222222222222222222222222222222222222222" { + t.Fatalf("withdraw.destination = %q", cfg.Withdraw.Destination) + } + if cfg.SendAsset.Token != "PURR:0x1" { + t.Fatalf("send_asset.token = %q", cfg.SendAsset.Token) + } + if cfg.SetAbstraction.Value != "disabled" { + t.Fatalf("set_abstraction.value = %q", cfg.SetAbstraction.Value) + } +} + +func TestParseIntegrationLiveConfig_InvalidApproveAgentAddress(t *testing.T) { + _, err := parseIntegrationLiveConfig(`{"approve_agent_address":"bad"}`) + if err == nil { + t.Fatal("expected invalid approve_agent_address error") + } + if !strings.Contains(err.Error(), "approve_agent_address") { + t.Fatalf("error = %q, want approve_agent_address", err.Error()) + } +} + +func TestIntegrationLiveConfig_MissingOneWayFields(t *testing.T) { + cfg := integrationLiveConfig{} + missing := cfg.missingOneWayFields() + if len(missing) != 7 { + t.Fatalf("missing len = %d, want 7: %v", len(missing), missing) + } +} + func findOpenOrderByCloid(orders []map[string]any, cloid string) (map[string]any, bool) { for _, ord := range orders { if got, ok := ord["cloid"].(string); ok && got == cloid { @@ -1055,16 +1209,14 @@ func TestIntegration_AccountDryRunNonceFreshness(t *testing.T) { } func TestIntegration_AccountLiveReversibleFlows(t *testing.T) { - if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { - t.Skip("skipping live account reversible flows: HL_TEST_PRIVATE_KEY is not set") - } + requireIntegrationLivePrivateKey(t) + cfg := requireIntegrationLiveReversibleConfig(t) - req := requiredLiveEnv(t, "HL_TEST_ACCOUNT_TRANSFER_AMOUNT") - agentAddr := strings.TrimSpace(os.Getenv("HL_TEST_APPROVE_AGENT_ADDRESS")) + agentAddr := cfg.ApproveAgentAddress if agentAddr == "" { agentAddr = newIntegrationAddress(t) } - amount := req["HL_TEST_ACCOUNT_TRANSFER_AMOUNT"] + amount := cfg.TransferAmount agentName := fmt.Sprintf("hlgo%010d", time.Now().Unix()%10_000_000_000) if len(agentName) > 16 { t.Fatalf("generated agentName exceeds 16 chars: %q", agentName) @@ -1190,26 +1342,14 @@ func TestIntegration_AccountLiveReversibleFlows(t *testing.T) { } func TestIntegration_AccountLiveOneWayOperations(t *testing.T) { - if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { - t.Skip("skipping live account one-way operations: HL_TEST_PRIVATE_KEY is not set") - } - - req := requiredLiveEnv( - t, - "HL_TEST_WITHDRAW_DESTINATION", - "HL_TEST_WITHDRAW_AMOUNT", - "HL_TEST_SEND_ASSET_DESTINATION", - "HL_TEST_SEND_ASSET_TOKEN", - "HL_TEST_SEND_ASSET_AMOUNT", - "HL_TEST_SET_ABSTRACTION_USER", - "HL_TEST_SET_ABSTRACTION_VALUE", - ) + requireIntegrationLivePrivateKey(t) + cfg := requireIntegrationLiveOneWayConfig(t) t.Log("step 1: live withdraw") stdout, stderr, code := runIntegrationHLGOWithRetry(t, "account", "withdraw", - "--destination", req["HL_TEST_WITHDRAW_DESTINATION"], - "--amount", req["HL_TEST_WITHDRAW_AMOUNT"], + "--destination", cfg.Withdraw.Destination, + "--amount", cfg.Withdraw.Amount, "--confirm", ) assertNoSecretLeak(t, stdout, stderr) @@ -1226,9 +1366,9 @@ func TestIntegration_AccountLiveOneWayOperations(t *testing.T) { t.Log("step 2: live send-asset") stdout, stderr, code = runIntegrationHLGOWithRetry(t, "account", "send-asset", - "--destination", req["HL_TEST_SEND_ASSET_DESTINATION"], - "--token", req["HL_TEST_SEND_ASSET_TOKEN"], - "--amount", req["HL_TEST_SEND_ASSET_AMOUNT"], + "--destination", cfg.SendAsset.Destination, + "--token", cfg.SendAsset.Token, + "--amount", cfg.SendAsset.Amount, "--confirm", ) assertNoSecretLeak(t, stdout, stderr) @@ -1245,8 +1385,8 @@ func TestIntegration_AccountLiveOneWayOperations(t *testing.T) { t.Log("step 3: live set-abstraction") stdout, stderr, code = runIntegrationHLGOWithRetry(t, "account", "set-abstraction", - "--user", req["HL_TEST_SET_ABSTRACTION_USER"], - "--abstraction", req["HL_TEST_SET_ABSTRACTION_VALUE"], + "--user", cfg.SetAbstraction.User, + "--abstraction", cfg.SetAbstraction.Value, ) assertNoSecretLeak(t, stdout, stderr) if code == 0 {