kalshi-cli A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: . When to use Use this skill when: - Creating, amending, or canceling trading orders on Kalshi - Querying market data, events, series, or orderbooks - Managing portfolio positions, fills, settlements, or subaccounts - Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions) - Managing authentication credentials and API keys - Working with RFQs (Request for Quotes) and block trading - Managing order groups for grouped order execution - Checking exchange status, sche…

\n order: 0\n - title: Bug Fixes\n regexp: '^.*?fix(\\([[:word:]]+\\))??!?:.+

kalshi-cli A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: . When to use Use this skill when: - Creating, amending, or canceling trading orders on Kalshi - Querying market data, events, series, or orderbooks - Managing portfolio positions, fills, settlements, or subaccounts - Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions) - Managing authentication credentials and API keys - Working with RFQs (Request for Quotes) and block trading - Managing order groups for grouped order execution - Checking exchange status, sche…

\n order: 1\n - title: Performance\n regexp: '^.*?perf(\\([[:word:]]+\\))??!?:.+

kalshi-cli A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: . When to use Use this skill when: - Creating, amending, or canceling trading orders on Kalshi - Querying market data, events, series, or orderbooks - Managing portfolio positions, fills, settlements, or subaccounts - Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions) - Managing authentication credentials and API keys - Working with RFQs (Request for Quotes) and block trading - Managing order groups for grouped order execution - Checking exchange status, sche…

\n order: 2\n - title: Refactoring\n regexp: '^.*?refactor(\\([[:word:]]+\\))??!?:.+

kalshi-cli A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: . When to use Use this skill when: - Creating, amending, or canceling trading orders on Kalshi - Querying market data, events, series, or orderbooks - Managing portfolio positions, fills, settlements, or subaccounts - Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions) - Managing authentication credentials and API keys - Working with RFQs (Request for Quotes) and block trading - Managing order groups for grouped order execution - Checking exchange status, sche…

\n order: 3\n - title: Others\n order: 999\n\nbrews:\n - repository:\n owner: 6missedcalls\n name: homebrew-tap\n token: \"{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}\"\n directory: Formula\n homepage: \"https://github.com/6missedcalls/kalshi-cli\"\n description: \"CLI for the Kalshi prediction market exchange\"\n license: \"MIT\"\n test: |\n system \"#{bin}/kalshi-cli\", \"version\"\n install: |\n bin.install \"kalshi-cli\"\n\nrelease:\n github:\n owner: 6missedcalls\n name: kalshi-cli\n draft: false\n prerelease: auto\n name_template: \"v{{.Version}}\"\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":1763,"content_sha256":"ae8efd5a1094913a38b67e3cf6c2f844bee6b2bf06dd60afea942a360a7e84d5"},{"filename":"cmd/kalshi-cli/main.go","content":"package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/cmd\"\n)\n\nvar (\n\tversion = \"dev\"\n\tcommit = \"none\"\n\tdate = \"unknown\"\n)\n\nfunc main() {\n\tcmd.SetVersionInfo(version, commit, date)\n\tif err := cmd.Execute(); err != nil {\n\t\tcmd.PrintError(err)\n\t\tos.Exit(1)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":285,"content_sha256":"83de877c53f16937bb77b31b4ba50e538410839ba277b94a6bc2edc832febd90"},{"filename":"internal/api/account_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestListAPIKeys(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse apiKeysResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns API keys successfully\",\n\t\t\tserverResponse: apiKeysResponse{\n\t\t\t\tAPIKeys: []APIKey{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"key-1\",\n\t\t\t\t\t\tName: \"Trading Bot\",\n\t\t\t\t\t\tCreatedTime: JSONTime{Time: now.Add(-24 * time.Hour)},\n\t\t\t\t\t\tScopes: []string{\"read\", \"trade\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"key-2\",\n\t\t\t\t\t\tName: \"Read Only\",\n\t\t\t\t\t\tCreatedTime: JSONTime{Time: now.Add(-48 * time.Hour)},\n\t\t\t\t\t\tScopes: []string{\"read\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty API keys list\",\n\t\t\tserverResponse: apiKeysResponse{\n\t\t\t\tAPIKeys: []APIKey{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: apiKeysResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\tserverResponse: apiKeysResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\tkeys, err := client.ListAPIKeys(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(keys) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d API keys, got %d\", tt.wantCount, len(keys))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateAPIKey(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\trequest CreateAPIKeyRequest\n\t\tserverResponse CreateAPIKeyResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"creates API key successfully\",\n\t\t\trequest: CreateAPIKeyRequest{Name: \"New Trading Bot\"},\n\t\t\tserverResponse: CreateAPIKeyResponse{\n\t\t\t\tAPIKey: APIKey{\n\t\t\t\t\tID: \"new-key-id\",\n\t\t\t\t\tName: \"New Trading Bot\",\n\t\t\t\t\tCreatedTime: JSONTime{Time: now},\n\t\t\t\t\tScopes: []string{\"read\", \"trade\"},\n\t\t\t\t},\n\t\t\t\tPrivateKey: \"-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"creates API key with empty name\",\n\t\t\trequest: CreateAPIKeyRequest{Name: \"\"},\n\t\t\tserverResponse: CreateAPIKeyResponse{\n\t\t\t\tAPIKey: APIKey{\n\t\t\t\t\tID: \"new-key-id\",\n\t\t\t\t\tName: \"\",\n\t\t\t\t\tCreatedTime: JSONTime{Time: now},\n\t\t\t\t\tScopes: []string{\"read\"},\n\t\t\t\t},\n\t\t\t\tPrivateKey: \"-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\trequest: CreateAPIKeyRequest{Name: \"Test Key\"},\n\t\t\tserverResponse: CreateAPIKeyResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\trequest: CreateAPIKeyRequest{Name: \"Test Key\"},\n\t\t\tserverResponse: CreateAPIKeyResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tvar req CreateAPIKeyRequest\n\t\t\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\t\t\tt.Errorf(\"failed to decode request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif req.Name != tt.request.Name {\n\t\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, req.Name)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\tresp, err := client.CreateAPIKey(context.Background(), tt.request)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.APIKey.Name != tt.request.Name {\n\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, resp.APIKey.Name)\n\t\t\t}\n\n\t\t\tif resp.PrivateKey == \"\" {\n\t\t\t\tt.Error(\"expected private key to be returned\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDeleteAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tkeyID string\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"deletes API key successfully\",\n\t\t\tkeyID: \"key-to-delete\",\n\t\t\tserverStatus: http.StatusNoContent,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"deletes API key successfully with 200\",\n\t\t\tkeyID: \"key-to-delete\",\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tkeyID: \"nonexistent-key\",\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tkeyID: \"key-id\",\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\tkeyID: \"key-id\",\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tt.Errorf(\"expected DELETE request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/api-keys/\" + tt.keyID\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\terr := client.DeleteAPIKey(context.Background(), tt.keyID)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetAPILimits(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse APILimitsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns API limits successfully\",\n\t\t\tserverResponse: APILimitsResponse{\n\t\t\t\tRateLimit: 100,\n\t\t\t\tMaxOrdersPerCall: 50,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: APILimitsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\tserverResponse: APILimitsResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/account/api-limits\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\tlimits, err := client.GetAPILimits(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif limits.RateLimit != tt.serverResponse.RateLimit {\n\t\t\t\tt.Errorf(\"expected rate limit %d, got %d\", tt.serverResponse.RateLimit, limits.RateLimit)\n\t\t\t}\n\n\t\t\tif limits.MaxOrdersPerCall != tt.serverResponse.MaxOrdersPerCall {\n\t\t\t\tt.Errorf(\"expected max orders per call %d, got %d\", tt.serverResponse.MaxOrdersPerCall, limits.MaxOrdersPerCall)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGenerateAPIKey(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\trequest GenerateAPIKeyRequest\n\t\tserverResponse GenerateAPIKeyResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"generates API key pair successfully\",\n\t\t\trequest: GenerateAPIKeyRequest{Name: \"Generated Key\"},\n\t\t\tserverResponse: GenerateAPIKeyResponse{\n\t\t\t\tAPIKey: APIKey{\n\t\t\t\t\tID: \"generated-key-id\",\n\t\t\t\t\tName: \"Generated Key\",\n\t\t\t\t\tCreatedTime: JSONTime{Time: now},\n\t\t\t\t\tScopes: []string{\"read\", \"trade\"},\n\t\t\t\t},\n\t\t\t\tPrivateKey: \"-----BEGIN RSA PRIVATE KEY-----\\nMIIE...\\n-----END RSA PRIVATE KEY-----\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\trequest: GenerateAPIKeyRequest{Name: \"Test Key\"},\n\t\t\tserverResponse: GenerateAPIKeyResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\trequest: GenerateAPIKeyRequest{Name: \"Test Key\"},\n\t\t\tserverResponse: GenerateAPIKeyResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/api-keys/generate\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tvar req GenerateAPIKeyRequest\n\t\t\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\t\t\tt.Errorf(\"failed to decode request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif req.Name != tt.request.Name {\n\t\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, req.Name)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\tresp, err := client.GenerateAPIKey(context.Background(), tt.request)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.APIKey.Name != tt.request.Name {\n\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, resp.APIKey.Name)\n\t\t\t}\n\n\t\t\tif resp.PrivateKey == \"\" {\n\t\t\t\tt.Error(\"expected private key to be returned\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateAPIKeyWithPublicKey(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttestPublicKey := \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBg...\\n-----END PUBLIC KEY-----\"\n\n\ttests := []struct {\n\t\tname string\n\t\trequest CreateAPIKeyWithPublicKeyRequest\n\t\tserverResponse CreateAPIKeyWithPublicKeyResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"creates API key with public key successfully\",\n\t\t\trequest: CreateAPIKeyWithPublicKeyRequest{\n\t\t\t\tName: \"My Key\",\n\t\t\t\tPublicKey: testPublicKey,\n\t\t\t},\n\t\t\tserverResponse: CreateAPIKeyWithPublicKeyResponse{\n\t\t\t\tAPIKey: APIKey{\n\t\t\t\t\tID: \"custom-key-id\",\n\t\t\t\t\tName: \"My Key\",\n\t\t\t\t\tCreatedTime: JSONTime{Time: now},\n\t\t\t\t\tScopes: []string{\"read\", \"trade\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles missing public key error\",\n\t\t\trequest: CreateAPIKeyWithPublicKeyRequest{\n\t\t\t\tName: \"My Key\",\n\t\t\t\tPublicKey: \"\",\n\t\t\t},\n\t\t\tserverResponse: CreateAPIKeyWithPublicKeyResponse{},\n\t\t\tserverStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\trequest: CreateAPIKeyWithPublicKeyRequest{\n\t\t\t\tName: \"Test Key\",\n\t\t\t\tPublicKey: testPublicKey,\n\t\t\t},\n\t\t\tserverResponse: CreateAPIKeyWithPublicKeyResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\trequest: CreateAPIKeyWithPublicKeyRequest{\n\t\t\t\tName: \"Test Key\",\n\t\t\t\tPublicKey: testPublicKey,\n\t\t\t},\n\t\t\tserverResponse: CreateAPIKeyWithPublicKeyResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/api-keys\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tvar req CreateAPIKeyWithPublicKeyRequest\n\t\t\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\t\t\tt.Errorf(\"failed to decode request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif req.Name != tt.request.Name {\n\t\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, req.Name)\n\t\t\t\t}\n\n\t\t\t\tif req.PublicKey != tt.request.PublicKey {\n\t\t\t\t\tt.Errorf(\"expected public key %q, got %q\", tt.request.PublicKey, req.PublicKey)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := accountTestClient(t, server.URL)\n\t\t\tresp, err := client.CreateAPIKeyWithPublicKey(context.Background(), tt.request)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.APIKey.Name != tt.request.Name {\n\t\t\t\tt.Errorf(\"expected name %q, got %q\", tt.request.Name, resp.APIKey.Name)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// accountTestClient creates a test client with the given base URL for account tests\nfunc accountTestClient(t *testing.T, serverURL string) *Client {\n\tt.Helper()\n\tclient := NewClient(nil, nil)\n\tclient.SetBaseURL(serverURL)\n\treturn client\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":14911,"content_sha256":"8f132703aca245d0c4a3f7cb9316e72a1de5fe3ead9ae1dfeabc32ca34336d97"},{"filename":"internal/api/auth_test.go","content":"package api\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/x509\"\n\t\"encoding/pem\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestNewSigner(t *testing.T) {\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key-id\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"NewSigner failed: %v\", err)\n\t}\n\n\tif signer == nil {\n\t\tt.Fatal(\"NewSigner returned nil signer\")\n\t}\n\n\tif signer.APIKeyID() != \"test-api-key-id\" {\n\t\tt.Errorf(\"expected API key ID 'test-api-key-id', got '%s'\", signer.APIKeyID())\n\t}\n}\n\nfunc TestNewSignerFromPEM(t *testing.T) {\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tpemBytes := pem.EncodeToMemory(&pem.Block{\n\t\tType: \"RSA PRIVATE KEY\",\n\t\tBytes: x509.MarshalPKCS1PrivateKey(privateKey),\n\t})\n\n\tsigner, err := NewSignerFromPEM(\"test-api-key-id\", string(pemBytes))\n\tif err != nil {\n\t\tt.Fatalf(\"NewSignerFromPEM failed: %v\", err)\n\t}\n\n\tif signer == nil {\n\t\tt.Fatal(\"NewSignerFromPEM returned nil signer\")\n\t}\n}\n\nfunc TestNewSignerFromPEM_InvalidPEM(t *testing.T) {\n\t_, err := NewSignerFromPEM(\"test-api-key-id\", \"invalid-pem-data\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid PEM data\")\n\t}\n}\n\nfunc TestSign(t *testing.T) {\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key-id\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"NewSigner failed: %v\", err)\n\t}\n\n\ttimestamp := time.Now().UTC()\n\tmethod := \"GET\"\n\tpath := \"/trade-api/v2/markets\"\n\n\tsignature, err := signer.Sign(timestamp, method, path)\n\tif err != nil {\n\t\tt.Fatalf(\"Sign failed: %v\", err)\n\t}\n\n\tif signature == \"\" {\n\t\tt.Fatal(\"Sign returned empty signature\")\n\t}\n\n\t// Signature should be base64 encoded\n\tif len(signature) \u003c 100 {\n\t\tt.Errorf(\"signature seems too short: %d chars\", len(signature))\n\t}\n}\n\nfunc TestSignWithBody(t *testing.T) {\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key-id\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"NewSigner failed: %v\", err)\n\t}\n\n\ttimestamp := time.Now().UTC()\n\tmethod := \"POST\"\n\tpath := \"/trade-api/v2/orders\"\n\tsignature, err := signer.Sign(timestamp, method, path)\n\tif err != nil {\n\t\tt.Fatalf(\"Sign failed: %v\", err)\n\t}\n\n\tif signature == \"\" {\n\t\tt.Fatal(\"Sign returned empty signature\")\n\t}\n}\n\nfunc TestBuildAuthMessage(t *testing.T) {\n\tts := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)\n\ttsMs := strconv.FormatInt(ts.UnixMilli(), 10)\n\n\ttests := []struct {\n\t\tname string\n\t\ttimestamp time.Time\n\t\tmethod string\n\t\tpath string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"GET request\",\n\t\t\ttimestamp: ts,\n\t\t\tmethod: \"GET\",\n\t\t\tpath: \"/trade-api/v2/markets\",\n\t\t\texpected: tsMs + \"GET/trade-api/v2/markets\",\n\t\t},\n\t\t{\n\t\t\tname: \"POST request\",\n\t\t\ttimestamp: ts,\n\t\t\tmethod: \"POST\",\n\t\t\tpath: \"/trade-api/v2/orders\",\n\t\t\texpected: tsMs + \"POST/trade-api/v2/orders\",\n\t\t},\n\t\t{\n\t\t\tname: \"DELETE request\",\n\t\t\ttimestamp: ts,\n\t\t\tmethod: \"DELETE\",\n\t\t\tpath: \"/trade-api/v2/orders/abc123\",\n\t\t\texpected: tsMs + \"DELETE/trade-api/v2/orders/abc123\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmsg := BuildAuthMessage(tt.timestamp, tt.method, tt.path)\n\t\t\tif msg != tt.expected {\n\t\t\t\tt.Errorf(\"expected message:\\n%s\\ngot:\\n%s\", tt.expected, msg)\n\t\t\t}\n\t\t})\n\t}\n}\n\n\nfunc generateTestKey() (*rsa.PrivateKey, error) {\n\treturn rsa.GenerateKey(rand.Reader, 2048)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3570,"content_sha256":"c934fe7fbbdf9ac42275d660e138abb79aa5684ee43c9a4d0d40a980ea85e224"},{"filename":"internal/api/auth.go","content":"package api\n\nimport (\n\t\"crypto\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/x509\"\n\t\"encoding/base64\"\n\t\"encoding/pem\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// Signer handles RSA signature generation for Kalshi API authentication\ntype Signer struct {\n\tapiKeyID string\n\tprivateKey *rsa.PrivateKey\n}\n\n// NewSigner creates a new signer with the given API key ID and private key\nfunc NewSigner(apiKeyID string, privateKey *rsa.PrivateKey) (*Signer, error) {\n\tif apiKeyID == \"\" {\n\t\treturn nil, errors.New(\"API key ID is required\")\n\t}\n\tif privateKey == nil {\n\t\treturn nil, errors.New(\"private key is required\")\n\t}\n\treturn &Signer{\n\t\tapiKeyID: apiKeyID,\n\t\tprivateKey: privateKey,\n\t}, nil\n}\n\n// NewSignerFromPEM creates a new signer from a PEM-encoded private key\nfunc NewSignerFromPEM(apiKeyID string, pemData string) (*Signer, error) {\n\tblock, _ := pem.Decode([]byte(pemData))\n\tif block == nil {\n\t\treturn nil, errors.New(\"failed to decode PEM block\")\n\t}\n\n\tvar privateKey *rsa.PrivateKey\n\tvar err error\n\n\tswitch block.Type {\n\tcase \"RSA PRIVATE KEY\":\n\t\tprivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)\n\tcase \"PRIVATE KEY\":\n\t\tkey, parseErr := x509.ParsePKCS8PrivateKey(block.Bytes)\n\t\tif parseErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to parse PKCS8 private key: %w\", parseErr)\n\t\t}\n\t\tvar ok bool\n\t\tprivateKey, ok = key.(*rsa.PrivateKey)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"private key is not RSA\")\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported PEM block type: %s\", block.Type)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse private key: %w\", err)\n\t}\n\n\treturn NewSigner(apiKeyID, privateKey)\n}\n\n// APIKeyID returns the API key ID\nfunc (s *Signer) APIKeyID() string {\n\treturn s.apiKeyID\n}\n\n// Sign generates a signature for the given request parameters.\n// Uses RSA-PSS with SHA-256 and millisecond Unix timestamp, matching Kalshi's API spec.\nfunc (s *Signer) Sign(timestamp time.Time, method, path string) (string, error) {\n\tmessage := BuildAuthMessage(timestamp, method, path)\n\n\t// RSA-PSS signature (NOT PKCS1v15) — required by Kalshi API\n\tmsgBytes := []byte(message)\n\th := crypto.SHA256.New()\n\th.Write(msgBytes)\n\thashed := h.Sum(nil)\n\n\tsignature, err := rsa.SignPSS(rand.Reader, s.privateKey, crypto.SHA256, hashed, &rsa.PSSOptions{\n\t\tSaltLength: rsa.PSSSaltLengthEqualsHash,\n\t})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to sign message: %w\", err)\n\t}\n\n\treturn base64.StdEncoding.EncodeToString(signature), nil\n}\n\n// BuildAuthMessage constructs the message to be signed.\n// Format: timestamp_ms + METHOD + /trade-api/v2/path (NO body)\nfunc BuildAuthMessage(timestamp time.Time, method, path string) string {\n\tts := strconv.FormatInt(timestamp.UnixMilli(), 10)\n\treturn ts + method + path\n}\n\n// TimestampHeader returns the timestamp as milliseconds since epoch (Kalshi format)\nfunc TimestampHeader(t time.Time) string {\n\treturn strconv.FormatInt(t.UnixMilli(), 10)\n}\n\n// GenerateKeyPair generates a new RSA key pair for API authentication\nfunc GenerateKeyPair() (*rsa.PrivateKey, error) {\n\treturn rsa.GenerateKey(rand.Reader, 4096)\n}\n\n// EncodePrivateKeyPEM encodes an RSA private key to PEM format\nfunc EncodePrivateKeyPEM(key *rsa.PrivateKey) string {\n\tblock := &pem.Block{\n\t\tType: \"RSA PRIVATE KEY\",\n\t\tBytes: x509.MarshalPKCS1PrivateKey(key),\n\t}\n\treturn string(pem.EncodeToMemory(block))\n}\n\n// EncodePublicKeyPEM encodes an RSA public key to PEM format\nfunc EncodePublicKeyPEM(key *rsa.PublicKey) (string, error) {\n\tpubBytes, err := x509.MarshalPKIXPublicKey(key)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to marshal public key: %w\", err)\n\t}\n\tblock := &pem.Block{\n\t\tType: \"PUBLIC KEY\",\n\t\tBytes: pubBytes,\n\t}\n\treturn string(pem.EncodeToMemory(block)), nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3702,"content_sha256":"6de5e77ea7559602e62fb5aa1eca98ff79fe949ec586f110a8541fd2da1b270b"},{"filename":"internal/api/candlesticks_v2_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// These tests send the REAL Kalshi v2 API JSON format (nested price object,\n// end_period_ts as unix int) and verify our models correctly parse it.\n// If these fail, it means our Candlestick model doesn't match the live API.\n\nfunc TestGetCandlesticks_RealV2Format(t *testing.T) {\n\t// This is the actual JSON format returned by Kalshi's v2 API\n\trealResponse := `{\n\t\t\"ticker\": \"BTC-100K\",\n\t\t\"candlesticks\": [\n\t\t\t{\n\t\t\t\t\"end_period_ts\": 1704067200,\n\t\t\t\t\"price\": {\n\t\t\t\t\t\"open\": 45,\n\t\t\t\t\t\"open_dollars\": \"0.4500\",\n\t\t\t\t\t\"low\": 42,\n\t\t\t\t\t\"low_dollars\": \"0.4200\",\n\t\t\t\t\t\"high\": 48,\n\t\t\t\t\t\"high_dollars\": \"0.4800\",\n\t\t\t\t\t\"close\": 47,\n\t\t\t\t\t\"close_dollars\": \"0.4700\",\n\t\t\t\t\t\"mean\": 46,\n\t\t\t\t\t\"mean_dollars\": \"0.4600\",\n\t\t\t\t\t\"previous\": 44,\n\t\t\t\t\t\"previous_dollars\": \"0.4400\"\n\t\t\t\t},\n\t\t\t\t\"yes_bid\": {\n\t\t\t\t\t\"open\": 44,\n\t\t\t\t\t\"open_dollars\": \"0.4400\",\n\t\t\t\t\t\"low\": 41,\n\t\t\t\t\t\"low_dollars\": \"0.4100\",\n\t\t\t\t\t\"high\": 47,\n\t\t\t\t\t\"high_dollars\": \"0.4700\",\n\t\t\t\t\t\"close\": 46,\n\t\t\t\t\t\"close_dollars\": \"0.4600\"\n\t\t\t\t},\n\t\t\t\t\"yes_ask\": {\n\t\t\t\t\t\"open\": 46,\n\t\t\t\t\t\"open_dollars\": \"0.4600\",\n\t\t\t\t\t\"low\": 43,\n\t\t\t\t\t\"low_dollars\": \"0.4300\",\n\t\t\t\t\t\"high\": 49,\n\t\t\t\t\t\"high_dollars\": \"0.4900\",\n\t\t\t\t\t\"close\": 48,\n\t\t\t\t\t\"close_dollars\": \"0.4800\"\n\t\t\t\t},\n\t\t\t\t\"volume\": 1500,\n\t\t\t\t\"open_interest\": 500\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"end_period_ts\": 1704070800,\n\t\t\t\t\"price\": {\n\t\t\t\t\t\"open\": 47,\n\t\t\t\t\t\"open_dollars\": \"0.4700\",\n\t\t\t\t\t\"low\": 45,\n\t\t\t\t\t\"low_dollars\": \"0.4500\",\n\t\t\t\t\t\"high\": 52,\n\t\t\t\t\t\"high_dollars\": \"0.5200\",\n\t\t\t\t\t\"close\": 50,\n\t\t\t\t\t\"close_dollars\": \"0.5000\"\n\t\t\t\t},\n\t\t\t\t\"yes_bid\": {\n\t\t\t\t\t\"open\": 46,\n\t\t\t\t\t\"open_dollars\": \"0.4600\",\n\t\t\t\t\t\"low\": 44,\n\t\t\t\t\t\"low_dollars\": \"0.4400\",\n\t\t\t\t\t\"high\": 51,\n\t\t\t\t\t\"high_dollars\": \"0.5100\",\n\t\t\t\t\t\"close\": 49,\n\t\t\t\t\t\"close_dollars\": \"0.4900\"\n\t\t\t\t},\n\t\t\t\t\"yes_ask\": {\n\t\t\t\t\t\"open\": 48,\n\t\t\t\t\t\"open_dollars\": \"0.4800\",\n\t\t\t\t\t\"low\": 46,\n\t\t\t\t\t\"low_dollars\": \"0.4600\",\n\t\t\t\t\t\"high\": 53,\n\t\t\t\t\t\"high_dollars\": \"0.5300\",\n\t\t\t\t\t\"close\": 51,\n\t\t\t\t\t\"close_dollars\": \"0.5100\"\n\t\t\t\t},\n\t\t\t\t\"volume\": 2000,\n\t\t\t\t\"open_interest\": 600\n\t\t\t}\n\t\t]\n\t}`\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(realResponse))\n\t}))\n\tdefer server.Close()\n\n\tclient := newTestClient(t, server.URL)\n\tresult, err := client.GetCandlesticks(context.Background(), GetCandlesticksParams{\n\t\tSeriesTicker: \"BTC-SERIES\",\n\t\tTicker: \"BTC-100K\",\n\t\tPeriod: \"1h\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(result.Candlesticks) != 2 {\n\t\tt.Fatalf(\"expected 2 candlesticks, got %d\", len(result.Candlesticks))\n\t}\n\n\t// Verify first candlestick values come from price.open/high/low/close\n\tc := result.Candlesticks[0]\n\tif c.Open != 45 {\n\t\tt.Errorf(\"expected Open=45, got %d\", c.Open)\n\t}\n\tif c.High != 48 {\n\t\tt.Errorf(\"expected High=48, got %d\", c.High)\n\t}\n\tif c.Low != 42 {\n\t\tt.Errorf(\"expected Low=42, got %d\", c.Low)\n\t}\n\tif c.Close != 47 {\n\t\tt.Errorf(\"expected Close=47, got %d\", c.Close)\n\t}\n\tif c.Volume != 1500 {\n\t\tt.Errorf(\"expected Volume=1500, got %d\", c.Volume)\n\t}\n\tif c.OpenInterest != 500 {\n\t\tt.Errorf(\"expected OpenInterest=500, got %d\", c.OpenInterest)\n\t}\n\n\t// Verify timestamp parsed from end_period_ts (unix seconds)\n\texpectedTime := time.Unix(1704067200, 0).UTC()\n\tif !c.PeriodEnd.Equal(expectedTime) {\n\t\tt.Errorf(\"expected PeriodEnd=%v, got %v\", expectedTime, c.PeriodEnd)\n\t}\n\n\t// Verify second candlestick\n\tc2 := result.Candlesticks[1]\n\tif c2.Open != 47 {\n\t\tt.Errorf(\"expected Open=47, got %d\", c2.Open)\n\t}\n\tif c2.Close != 50 {\n\t\tt.Errorf(\"expected Close=50, got %d\", c2.Close)\n\t}\n\texpectedTime2 := time.Unix(1704070800, 0).UTC()\n\tif !c2.PeriodEnd.Equal(expectedTime2) {\n\t\tt.Errorf(\"expected PeriodEnd=%v, got %v\", expectedTime2, c2.PeriodEnd)\n\t}\n}\n\nfunc TestGetEventCandlesticks_RealV2Format(t *testing.T) {\n\t// Event candlesticks have a different response shape:\n\t// market_tickers + market_candlesticks (array of arrays)\n\trealResponse := `{\n\t\t\"market_tickers\": [\"INXD-25FEB07-B5523.99\", \"INXD-25FEB07-B5524.99\"],\n\t\t\"market_candlesticks\": [\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t\"end_period_ts\": 1738886400,\n\t\t\t\t\t\"price\": {\n\t\t\t\t\t\t\"open\": 60,\n\t\t\t\t\t\t\"open_dollars\": \"0.6000\",\n\t\t\t\t\t\t\"low\": 55,\n\t\t\t\t\t\t\"low_dollars\": \"0.5500\",\n\t\t\t\t\t\t\"high\": 65,\n\t\t\t\t\t\t\"high_dollars\": \"0.6500\",\n\t\t\t\t\t\t\"close\": 62,\n\t\t\t\t\t\t\"close_dollars\": \"0.6200\"\n\t\t\t\t\t},\n\t\t\t\t\t\"volume\": 3000,\n\t\t\t\t\t\"open_interest\": 1200\n\t\t\t\t}\n\t\t\t],\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t\"end_period_ts\": 1738886400,\n\t\t\t\t\t\"price\": {\n\t\t\t\t\t\t\"open\": 30,\n\t\t\t\t\t\t\"open_dollars\": \"0.3000\",\n\t\t\t\t\t\t\"low\": 25,\n\t\t\t\t\t\t\"low_dollars\": \"0.2500\",\n\t\t\t\t\t\t\"high\": 35,\n\t\t\t\t\t\t\"high_dollars\": \"0.3500\",\n\t\t\t\t\t\t\"close\": 32,\n\t\t\t\t\t\t\"close_dollars\": \"0.3200\"\n\t\t\t\t\t},\n\t\t\t\t\t\"volume\": 1500,\n\t\t\t\t\t\"open_interest\": 800\n\t\t\t\t}\n\t\t\t]\n\t\t]\n\t}`\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(realResponse))\n\t}))\n\tdefer server.Close()\n\n\tclient := newTestClient(t, server.URL)\n\tcandlesticks, err := client.GetEventCandlesticks(context.Background(), CandlesticksParams{\n\t\tSeriesTicker: \"INXD\",\n\t\tTicker: \"INXD-25FEB07\",\n\t\tPeriod: \"1h\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// Event candlesticks should flatten all markets' candlesticks\n\tif len(candlesticks) == 0 {\n\t\tt.Fatal(\"expected at least 1 candlestick, got 0\")\n\t}\n\n\t// Check first candlestick parsed correctly\n\tc := candlesticks[0]\n\tif c.Open != 60 {\n\t\tt.Errorf(\"expected Open=60, got %d\", c.Open)\n\t}\n\tif c.High != 65 {\n\t\tt.Errorf(\"expected High=65, got %d\", c.High)\n\t}\n\tif c.Low != 55 {\n\t\tt.Errorf(\"expected Low=55, got %d\", c.Low)\n\t}\n\tif c.Close != 62 {\n\t\tt.Errorf(\"expected Close=62, got %d\", c.Close)\n\t}\n\tif c.Volume != 3000 {\n\t\tt.Errorf(\"expected Volume=3000, got %d\", c.Volume)\n\t}\n}\n\n// Test that existing old-format tests still produce correct results\n// when mock servers return the correct new format.\n// This verifies backward compatibility is maintained.\nfunc TestCandlestickResponse_OldFormatStillWorks(t *testing.T) {\n\t// Existing tests use this format (Go struct → JSON encoding).\n\t// After the fix, this format should still be parseable for internal usage.\n\tresp := models.CandlesticksResponse{\n\t\tCandlesticks: []models.Candlestick{\n\t\t\t{\n\t\t\t\tTicker: \"TEST\",\n\t\t\t\tOpen: 45,\n\t\t\t\tHigh: 48,\n\t\t\t\tLow: 42,\n\t\t\t\tClose: 47,\n\t\t\t\tVolume: 1000,\n\t\t\t\tOpenInterest: 500,\n\t\t\t},\n\t\t},\n\t}\n\n\tif len(resp.Candlesticks) != 1 {\n\t\tt.Fatalf(\"expected 1 candlestick, got %d\", len(resp.Candlesticks))\n\t}\n\tif resp.Candlesticks[0].Open != 45 {\n\t\tt.Errorf(\"expected Open=45, got %d\", resp.Candlesticks[0].Open)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6790,"content_sha256":"c10d74e01c9020383e6e30b27eac47144681e6a27e88725c67e5121168b810e0"},{"filename":"internal/api/client_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"crypto\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n)\n\nfunc TestNewClient(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n\n\tsigner := createTestSigner(t)\n\n\tclient := NewClient(cfg, signer)\n\n\tif client == nil {\n\t\tt.Fatal(\"NewClient returned nil\")\n\t}\n}\n\nfunc TestNewClient_NilConfig(t *testing.T) {\n\tsigner := createTestSigner(t)\n\n\tclient := NewClient(nil, signer)\n\n\tif client == nil {\n\t\tt.Fatal(\"NewClient with nil config should still create client with defaults\")\n\t}\n}\n\nfunc TestNewClient_NilSigner(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n\n\tclient := NewClient(cfg, nil)\n\n\tif client == nil {\n\t\tt.Fatal(\"NewClient with nil signer should still create client\")\n\t}\n}\n\nfunc TestClient_BaseURL_Demo(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t},\n\t}\n\n\tclient := NewClient(cfg, nil)\n\n\tif client.BaseURL() != config.DemoBaseURL {\n\t\tt.Errorf(\"expected demo base URL %s, got %s\", config.DemoBaseURL, client.BaseURL())\n\t}\n}\n\nfunc TestClient_BaseURL_Production(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: true,\n\t\t},\n\t}\n\n\tclient := NewClient(cfg, nil)\n\n\tif client.BaseURL() != config.ProdBaseURL {\n\t\tt.Errorf(\"expected production base URL %s, got %s\", config.ProdBaseURL, client.BaseURL())\n\t}\n}\n\nfunc TestClient_SetDebug(t *testing.T) {\n\tcfg := &config.Config{}\n\tclient := NewClient(cfg, nil)\n\n\t// Should not panic\n\tclient.SetDebug(true)\n\tclient.SetDebug(false)\n}\n\nfunc TestClient_Get_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tresp, err := client.Get(ctx, \"/test\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusOK {\n\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode())\n\t}\n}\n\nfunc TestClient_Get_WithContext_Cancellation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // Cancel immediately\n\n\t_, err := client.Get(ctx, \"/test\")\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error due to cancelled context\")\n\t}\n}\n\nfunc TestClient_Post_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\n\t\tvar body map[string]interface{}\n\t\tjson.NewDecoder(r.Body).Decode(&body)\n\n\t\tif body[\"ticker\"] != \"TEST-MARKET\" {\n\t\t\tt.Errorf(\"expected ticker TEST-MARKET, got %v\", body[\"ticker\"])\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusCreated)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"id\": \"order-123\"})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tbody := map[string]interface{}{\"ticker\": \"TEST-MARKET\", \"count\": 10}\n\tresp, err := client.Post(ctx, \"/orders\", body)\n\n\tif err != nil {\n\t\tt.Fatalf(\"Post failed: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusCreated {\n\t\tt.Errorf(\"expected status 201, got %d\", resp.StatusCode())\n\t}\n}\n\nfunc TestClient_Put_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPut {\n\t\t\tt.Errorf(\"expected PUT, got %s\", r.Method)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"updated\": \"true\"})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tbody := map[string]interface{}{\"count\": 20}\n\tresp, err := client.Put(ctx, \"/orders/123\", body)\n\n\tif err != nil {\n\t\tt.Fatalf(\"Put failed: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusOK {\n\t\tt.Errorf(\"expected status 200, got %d\", resp.StatusCode())\n\t}\n}\n\nfunc TestClient_Delete_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tresp, err := client.DeleteRaw(ctx, \"/orders/123\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"Delete failed: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusNoContent {\n\t\tt.Errorf(\"expected status 204, got %d\", resp.StatusCode())\n\t}\n}\n\nfunc TestClient_RequestSigning(t *testing.T) {\n\tvar receivedKey string\n\tvar receivedSignature string\n\tvar receivedTimestamp string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedKey = r.Header.Get(\"KALSHI-ACCESS-KEY\")\n\t\treceivedSignature = r.Header.Get(\"KALSHI-ACCESS-SIGNATURE\")\n\t\treceivedTimestamp = r.Header.Get(\"KALSHI-ACCESS-TIMESTAMP\")\n\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsigner := createTestSigner(t)\n\tclient := createTestClientWithURLAndSigner(t, server.URL, signer)\n\n\tctx := context.Background()\n\t_, err := client.Get(ctx, \"/test\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\n\tif receivedKey == \"\" {\n\t\tt.Error(\"KALSHI-ACCESS-KEY header not set\")\n\t}\n\n\tif receivedKey != \"test-api-key\" {\n\t\tt.Errorf(\"expected KALSHI-ACCESS-KEY 'test-api-key', got '%s'\", receivedKey)\n\t}\n\n\tif receivedSignature == \"\" {\n\t\tt.Error(\"KALSHI-ACCESS-SIGNATURE header not set\")\n\t}\n\n\tif receivedTimestamp == \"\" {\n\t\tt.Error(\"KALSHI-ACCESS-TIMESTAMP header not set\")\n\t}\n\n\t// Timestamp should be milliseconds since epoch\n\t_, parseErr := strconv.ParseInt(receivedTimestamp, 10, 64)\n\tif parseErr != nil {\n\t\tt.Errorf(\"KALSHI-ACCESS-TIMESTAMP should be milliseconds since epoch, got: %s\", receivedTimestamp)\n\t}\n}\n\nfunc TestClient_SigningExcludesQueryParams(t *testing.T) {\n\t// Bug: when a URL has query params (e.g. /path?limit=50),\n\t// the signed message should use only the path portion,\n\t// NOT include the query string. Kalshi API rejects signatures\n\t// that include query params in the signed message.\n\n\tvar receivedSignature string\n\tvar receivedTimestamp string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedSignature = r.Header.Get(\"KALSHI-ACCESS-SIGNATURE\")\n\t\treceivedTimestamp = r.Header.Get(\"KALSHI-ACCESS-TIMESTAMP\")\n\n\t\t// Verify query params are present in the actual HTTP request\n\t\tif r.URL.Query().Get(\"limit\") != \"50\" {\n\t\t\tt.Errorf(\"expected query param limit=50, got %s\", r.URL.RawQuery)\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsigner := createTestSigner(t)\n\tclient := createTestClientWithURLAndSigner(t, server.URL, signer)\n\n\tctx := context.Background()\n\t_, err := client.Get(ctx, \"/trade-api/v2/portfolio/settlements?limit=50\")\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\n\t// Parse the timestamp that was sent\n\ttsMs, err := strconv.ParseInt(receivedTimestamp, 10, 64)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse timestamp: %v\", err)\n\t}\n\tts := time.UnixMilli(tsMs)\n\n\t// Verify: the signature should be valid for the path WITHOUT query params.\n\t// RSA-PSS is non-deterministic, so we verify by checking the signature\n\t// against the expected message using the public key.\n\tsigBytes, err := base64.StdEncoding.DecodeString(receivedSignature)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to decode signature: %v\", err)\n\t}\n\n\t// The expected signed message uses path WITHOUT query params\n\texpectedMsg := BuildAuthMessage(ts, \"GET\", \"/trade-api/v2/portfolio/settlements\")\n\th := crypto.SHA256.New()\n\th.Write([]byte(expectedMsg))\n\thashed := h.Sum(nil)\n\n\t// Verify: if signing excluded query params, this should succeed\n\terr = rsa.VerifyPSS(&signer.privateKey.PublicKey, crypto.SHA256, hashed, sigBytes, &rsa.PSSOptions{\n\t\tSaltLength: rsa.PSSSaltLengthEqualsHash,\n\t})\n\tif err != nil {\n\t\tt.Errorf(\"signature verification failed: query params were included in signed path\\n\"+\n\t\t\t\" The signed message should use path only (no query string)\\n\"+\n\t\t\t\" verification error: %v\", err)\n\t}\n}\n\nfunc TestClient_APIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tjson.NewEncoder(w).Encode(map[string]interface{}{\n\t\t\t\"code\": \"INVALID_PARAM\",\n\t\t\t\"message\": \"Invalid ticker format\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tresp, err := client.Get(ctx, \"/test\")\n\n\t// The client should return the response even on error status codes\n\tif err != nil {\n\t\tt.Fatalf(\"Get should not return error for API errors: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusBadRequest {\n\t\tt.Errorf(\"expected status 400, got %d\", resp.StatusCode())\n\t}\n\n\t// Parse the error\n\tapiErr := ParseAPIError(resp)\n\tif apiErr == nil {\n\t\tt.Fatal(\"ParseAPIError returned nil for error response\")\n\t}\n\n\tif apiErr.Code != \"INVALID_PARAM\" {\n\t\tt.Errorf(\"expected code INVALID_PARAM, got %s\", apiErr.Code)\n\t}\n\n\tif apiErr.Message != \"Invalid ticker format\" {\n\t\tt.Errorf(\"expected message 'Invalid ticker format', got '%s'\", apiErr.Message)\n\t}\n}\n\nfunc TestAPIError_Error(t *testing.T) {\n\terr := &APIError{\n\t\tCode: \"RATE_LIMITED\",\n\t\tMessage: \"Too many requests\",\n\t\tStatusCode: 429,\n\t}\n\n\terrStr := err.Error()\n\tif !strings.Contains(errStr, \"RATE_LIMITED\") {\n\t\tt.Errorf(\"error string should contain code: %s\", errStr)\n\t}\n\tif !strings.Contains(errStr, \"Too many requests\") {\n\t\tt.Errorf(\"error string should contain message: %s\", errStr)\n\t}\n}\n\nfunc TestClient_RateLimiting_Retry(t *testing.T) {\n\tvar attempts int32\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tattempt := atomic.AddInt32(&attempts, 1)\n\n\t\tif attempt \u003c 3 {\n\t\t\tw.Header().Set(\"Retry-After\", \"1\")\n\t\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t\tjson.NewEncoder(w).Encode(map[string]interface{}{\n\t\t\t\t\"code\": \"RATE_LIMITED\",\n\t\t\t\t\"message\": \"Too many requests\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tresp, err := client.Get(ctx, \"/test\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed after retries: %v\", err)\n\t}\n\n\tif resp.StatusCode() != http.StatusOK {\n\t\tt.Errorf(\"expected status 200 after retries, got %d\", resp.StatusCode())\n\t}\n\n\tif atomic.LoadInt32(&attempts) \u003c 3 {\n\t\tt.Errorf(\"expected at least 3 attempts, got %d\", attempts)\n\t}\n}\n\nfunc TestClient_RateLimiting_MaxRetries(t *testing.T) {\n\tvar attempts int32\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tatomic.AddInt32(&attempts, 1)\n\t\tw.Header().Set(\"Retry-After\", \"1\")\n\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\tjson.NewEncoder(w).Encode(map[string]interface{}{\n\t\t\t\"code\": \"RATE_LIMITED\",\n\t\t\t\"message\": \"Too many requests\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tresp, _ := client.Get(ctx, \"/test\")\n\n\t// After max retries, should return the rate limit response\n\tif resp.StatusCode() != http.StatusTooManyRequests {\n\t\tt.Errorf(\"expected status 429 after max retries, got %d\", resp.StatusCode())\n\t}\n\n\t// Should have made multiple attempts\n\tif atomic.LoadInt32(&attempts) \u003c 2 {\n\t\tt.Errorf(\"expected multiple retry attempts, got %d\", attempts)\n\t}\n}\n\nfunc TestClient_ExponentialBackoff(t *testing.T) {\n\tvar requestTimes []time.Time\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequestTimes = append(requestTimes, time.Now())\n\n\t\tif len(requestTimes) \u003c 3 {\n\t\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t\treturn\n\t\t}\n\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClientWithURL(t, server.URL)\n\n\tctx := context.Background()\n\tclient.Get(ctx, \"/test\")\n\n\tif len(requestTimes) \u003c 3 {\n\t\tt.Skip(\"not enough retries to verify backoff\")\n\t}\n\n\t// Second retry should have longer delay than first\n\tdelay1 := requestTimes[1].Sub(requestTimes[0])\n\tdelay2 := requestTimes[2].Sub(requestTimes[1])\n\n\tif delay2 \u003c delay1 {\n\t\tt.Errorf(\"expected exponential backoff: delay1=%v, delay2=%v\", delay1, delay2)\n\t}\n}\n\nfunc TestIsRateLimitError(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tstatusCode int\n\t\texpected bool\n\t}{\n\t\t{\"429 is rate limit\", 429, true},\n\t\t{\"200 is not rate limit\", 200, false},\n\t\t{\"400 is not rate limit\", 400, false},\n\t\t{\"500 is not rate limit\", 500, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := IsRateLimitError(tt.statusCode)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsRateLimitError(%d) = %v, want %v\", tt.statusCode, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsServerError(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tstatusCode int\n\t\texpected bool\n\t}{\n\t\t{\"500 is server error\", 500, true},\n\t\t{\"502 is server error\", 502, true},\n\t\t{\"503 is server error\", 503, true},\n\t\t{\"504 is server error\", 504, true},\n\t\t{\"400 is not server error\", 400, false},\n\t\t{\"429 is not server error\", 429, false},\n\t\t{\"200 is not server error\", 200, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := IsServerError(tt.statusCode)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsServerError(%d) = %v, want %v\", tt.statusCode, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper functions\n\nfunc createTestSigner(t *testing.T) *Signer {\n\tt.Helper()\n\n\tprivateKey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create signer: %v\", err)\n\t}\n\n\treturn signer\n}\n\nfunc createTestClientWithURL(t *testing.T, baseURL string) *Client {\n\tt.Helper()\n\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t\tTimeout: 5 * time.Second,\n\t\t},\n\t}\n\n\tclient := NewClient(cfg, nil)\n\tclient.SetBaseURL(baseURL)\n\n\treturn client\n}\n\nfunc createTestClientWithURLAndSigner(t *testing.T, baseURL string, signer *Signer) *Client {\n\tt.Helper()\n\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t\tTimeout: 5 * time.Second,\n\t\t},\n\t}\n\n\tclient := NewClient(cfg, signer)\n\tclient.SetBaseURL(baseURL)\n\n\treturn client\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":15184,"content_sha256":"df435f4d594ccbc9e496e7504b950589816d08de6dc96d1c191308f50295fca8"},{"filename":"internal/api/client.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"math\"\n\t\"net/http\"\n\t\"strings\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/go-resty/resty/v2\"\n)\n\nconst (\n\tDefaultBaseURL = \"https://api.elections.kalshi.com\"\n\tTradeAPIPrefix = \"/trade-api/v2\"\n\tdefaultTimeout = 30 * time.Second\n\tmaxRetries = 5\n\tbaseRetryDelay = 100 * time.Millisecond\n\tmaxRetryDelay = 10 * time.Second\n\tretryMultiplier = 2.0\n\theaderTimestamp = \"KALSHI-ACCESS-TIMESTAMP\"\n\theaderAccessKey = \"KALSHI-ACCESS-KEY\"\n\theaderSignature = \"KALSHI-ACCESS-SIGNATURE\"\n)\n\n// Client handles HTTP requests to the Kalshi API\ntype Client struct {\n\tresty *resty.Client\n\tsigner *Signer\n\tbaseURL string\n\ttimeout time.Duration\n}\n\n// ClientOption is a functional option for configuring the client (legacy support)\ntype ClientOption func(*Client)\n\n// WithBaseURL sets a custom base URL (legacy support)\nfunc WithBaseURL(baseURL string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.baseURL = baseURL\n\t\tc.resty.SetBaseURL(baseURL)\n\t}\n}\n\n// NewClientLegacy creates a new API client using the legacy functional options pattern\n// Deprecated: Use NewClient(cfg, signer) instead\nfunc NewClientLegacy(signer *Signer, opts ...ClientOption) *Client {\n\tclient := &Client{\n\t\tresty: resty.New(),\n\t\tsigner: signer,\n\t\tbaseURL: DefaultBaseURL,\n\t\ttimeout: defaultTimeout,\n\t}\n\n\tclient.resty.SetBaseURL(DefaultBaseURL)\n\tclient.resty.SetTimeout(defaultTimeout)\n\tclient.resty.SetHeader(\"Content-Type\", \"application/json\")\n\tclient.resty.SetHeader(\"Accept\", \"application/json\")\n\n\t// Add request signing middleware\n\tclient.resty.OnBeforeRequest(client.signRequest)\n\n\t// Add retry configuration for rate limiting with exponential backoff\n\tclient.resty.SetRetryCount(maxRetries)\n\tclient.resty.SetRetryWaitTime(baseRetryDelay)\n\tclient.resty.SetRetryMaxWaitTime(maxRetryDelay)\n\tclient.resty.AddRetryCondition(func(resp *resty.Response, err error) bool {\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\t\treturn IsRateLimitError(resp.StatusCode()) || IsServerError(resp.StatusCode())\n\t})\n\tclient.resty.SetRetryAfter(func(c *resty.Client, resp *resty.Response) (time.Duration, error) {\n\t\treturn calculateBackoff(resp)\n\t})\n\n\t// Apply options\n\tfor _, opt := range opts {\n\t\topt(client)\n\t}\n\n\treturn client\n}\n\n// NewClient creates a new API client\nfunc NewClient(cfg *config.Config, signer *Signer) *Client {\n\tbaseURL := config.DemoBaseURL\n\ttimeout := defaultTimeout\n\n\tif cfg != nil {\n\t\tbaseURL = cfg.BaseURL()\n\t\tif cfg.API.Timeout > 0 {\n\t\t\ttimeout = cfg.API.Timeout\n\t\t}\n\t}\n\n\tclient := &Client{\n\t\tresty: resty.New(),\n\t\tsigner: signer,\n\t\tbaseURL: baseURL,\n\t\ttimeout: timeout,\n\t}\n\n\tclient.resty.SetBaseURL(baseURL)\n\tclient.resty.SetTimeout(timeout)\n\tclient.resty.SetHeader(\"Content-Type\", \"application/json\")\n\tclient.resty.SetHeader(\"Accept\", \"application/json\")\n\n\t// Add request signing middleware\n\tclient.resty.OnBeforeRequest(client.signRequest)\n\n\t// Add retry configuration for rate limiting with exponential backoff\n\tclient.resty.SetRetryCount(maxRetries)\n\tclient.resty.SetRetryWaitTime(baseRetryDelay)\n\tclient.resty.SetRetryMaxWaitTime(maxRetryDelay)\n\tclient.resty.AddRetryCondition(func(resp *resty.Response, err error) bool {\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\t\treturn IsRateLimitError(resp.StatusCode()) || IsServerError(resp.StatusCode())\n\t})\n\tclient.resty.SetRetryAfter(func(c *resty.Client, resp *resty.Response) (time.Duration, error) {\n\t\treturn calculateBackoff(resp)\n\t})\n\n\treturn client\n}\n\n// signRequest adds authentication headers to requests\nfunc (c *Client) signRequest(client *resty.Client, req *resty.Request) error {\n\tif c.signer == nil {\n\t\treturn nil\n\t}\n\n\ttimestamp := time.Now().UTC()\n\tpath := req.URL\n\n\t// Strip query params — Kalshi signs path only (no query string)\n\tif idx := strings.Index(path, \"?\"); idx != -1 {\n\t\tpath = path[:idx]\n\t}\n\n\t// Kalshi signs: timestamp_ms + METHOD + path (NO body)\n\tsignature, err := c.signer.Sign(timestamp, req.Method, path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to sign request: %w\", err)\n\t}\n\n\treq.SetHeader(headerTimestamp, TimestampHeader(timestamp))\n\treq.SetHeader(headerAccessKey, c.signer.APIKeyID())\n\treq.SetHeader(headerSignature, signature)\n\n\treturn nil\n}\n\n// calculateBackoff determines the retry delay using exponential backoff\nfunc calculateBackoff(resp *resty.Response) (time.Duration, error) {\n\t// Check for Retry-After header\n\tif retryAfter := resp.Header().Get(\"Retry-After\"); retryAfter != \"\" {\n\t\tseconds, err := strconv.Atoi(retryAfter)\n\t\tif err == nil && seconds > 0 {\n\t\t\treturn time.Duration(seconds) * time.Second, nil\n\t\t}\n\t}\n\n\t// Use exponential backoff\n\tattempt := resp.Request.Attempt\n\tdelay := float64(baseRetryDelay) * math.Pow(retryMultiplier, float64(attempt-1))\n\tif delay > float64(maxRetryDelay) {\n\t\tdelay = float64(maxRetryDelay)\n\t}\n\n\treturn time.Duration(delay), nil\n}\n\n// BaseURL returns the base URL of the API\nfunc (c *Client) BaseURL() string {\n\treturn c.baseURL\n}\n\n// SetBaseURL updates the base URL (useful for testing)\nfunc (c *Client) SetBaseURL(url string) {\n\tc.baseURL = url\n\tc.resty.SetBaseURL(url)\n}\n\n// SetDebug enables or disables debug logging\nfunc (c *Client) SetDebug(enabled bool) {\n\tc.resty.SetDebug(enabled)\n}\n\n// Get performs a GET request and returns the raw resty.Response\nfunc (c *Client) Get(ctx context.Context, path string) (*resty.Response, error) {\n\treturn c.resty.R().\n\t\tSetContext(ctx).\n\t\tGet(path)\n}\n\n// Post performs a POST request with a JSON body and returns the raw resty.Response\nfunc (c *Client) Post(ctx context.Context, path string, body interface{}) (*resty.Response, error) {\n\treturn c.resty.R().\n\t\tSetContext(ctx).\n\t\tSetBody(body).\n\t\tPost(path)\n}\n\n// Put performs a PUT request with a JSON body and returns the raw resty.Response\nfunc (c *Client) Put(ctx context.Context, path string, body interface{}) (*resty.Response, error) {\n\treturn c.resty.R().\n\t\tSetContext(ctx).\n\t\tSetBody(body).\n\t\tPut(path)\n}\n\n// DeleteRaw performs a DELETE request and returns the raw resty.Response\nfunc (c *Client) DeleteRaw(ctx context.Context, path string) (*resty.Response, error) {\n\treturn c.resty.R().\n\t\tSetContext(ctx).\n\t\tDelete(path)\n}\n\n// Legacy method signatures for backward compatibility with existing codebase\n\n// GetJSON performs a GET request and unmarshals response into result\nfunc (c *Client) GetJSON(ctx context.Context, path string, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodGet, path, nil, result)\n}\n\n// PostJSON performs a POST request and unmarshals response into result\nfunc (c *Client) PostJSON(ctx context.Context, path string, body interface{}, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodPost, path, body, result)\n}\n\n// PutJSON performs a PUT request and unmarshals response into result\nfunc (c *Client) PutJSON(ctx context.Context, path string, body interface{}, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodPut, path, body, result)\n}\n\n// PatchJSON performs a PATCH request and unmarshals response into result\nfunc (c *Client) PatchJSON(ctx context.Context, path string, body interface{}, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodPatch, path, body, result)\n}\n\n// DeleteJSON performs a DELETE request and unmarshals response into result\nfunc (c *Client) DeleteJSON(ctx context.Context, path string, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodDelete, path, nil, result)\n}\n\n// DeleteWithBody performs a DELETE request with a request body\nfunc (c *Client) DeleteWithBody(ctx context.Context, path string, body interface{}, result interface{}) error {\n\treturn c.DoRequest(ctx, http.MethodDelete, path, body, result)\n}\n\n// APIError represents an error response from the Kalshi API\ntype APIError struct {\n\tCode string `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tStatusCode int `json:\"-\"`\n}\n\n// Error implements the error interface\nfunc (e *APIError) Error() string {\n\tif e.Code != \"\" {\n\t\treturn fmt.Sprintf(\"API error [%d] %s: %s\", e.StatusCode, e.Code, e.Message)\n\t}\n\treturn fmt.Sprintf(\"API error [%d]: %s\", e.StatusCode, e.Message)\n}\n\n// ParseAPIError extracts an APIError from a response\nfunc ParseAPIError(resp *resty.Response) *APIError {\n\tif resp.StatusCode() >= 200 && resp.StatusCode() \u003c 300 {\n\t\treturn nil\n\t}\n\n\tvar apiErr APIError\n\tif err := json.Unmarshal(resp.Body(), &apiErr); err != nil {\n\t\treturn &APIError{\n\t\t\tCode: \"UNKNOWN\",\n\t\t\tMessage: string(resp.Body()),\n\t\t\tStatusCode: resp.StatusCode(),\n\t\t}\n\t}\n\n\tapiErr.StatusCode = resp.StatusCode()\n\treturn &apiErr\n}\n\n// IsRateLimitError checks if the status code indicates rate limiting\nfunc IsRateLimitError(statusCode int) bool {\n\treturn statusCode == http.StatusTooManyRequests\n}\n\n// IsServerError checks if the status code indicates a server error\nfunc IsServerError(statusCode int) bool {\n\treturn statusCode >= 500 && statusCode \u003c 600\n}\n\n// BuildQueryString builds a query string from a map of parameters\nfunc BuildQueryString(params map[string]string) string {\n\tif len(params) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvalues := url.Values{}\n\tfor k, v := range params {\n\t\tif v != \"\" {\n\t\t\tvalues.Set(k, v)\n\t\t}\n\t}\n\n\tif len(values) == 0 {\n\t\treturn \"\"\n\t}\n\n\treturn \"?\" + values.Encode()\n}\n\n// DoRequest performs an authenticated HTTP request (for backward compatibility)\nfunc (c *Client) DoRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {\n\tvar resp *resty.Response\n\tvar err error\n\n\treq := c.resty.R().SetContext(ctx)\n\n\tif body != nil {\n\t\treq.SetBody(body)\n\t}\n\n\tif result != nil {\n\t\treq.SetResult(result)\n\t}\n\n\tswitch method {\n\tcase http.MethodGet:\n\t\tresp, err = req.Get(path)\n\tcase http.MethodPost:\n\t\tresp, err = req.Post(path)\n\tcase http.MethodPut:\n\t\tresp, err = req.Put(path)\n\tcase http.MethodPatch:\n\t\tresp, err = req.Patch(path)\n\tcase http.MethodDelete:\n\t\tresp, err = req.Delete(path)\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported HTTP method: %s\", method)\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request failed: %w\", err)\n\t}\n\n\tif resp.StatusCode() >= 400 {\n\t\tapiErr := ParseAPIError(resp)\n\t\tif apiErr != nil {\n\t\t\treturn apiErr\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// GetExchangeStatus returns the current exchange status\nfunc (c *Client) GetExchangeStatus(ctx context.Context) (*ExchangeStatusResponse, error) {\n\tvar result ExchangeStatusResponse\n\tpath := TradeAPIPrefix + \"/exchange/status\"\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// ExchangeStatusResponse is the response for exchange status\ntype ExchangeStatusResponse struct {\n\tExchangeActive bool `json:\"exchange_active\"`\n\tTradingActive bool `json:\"trading_active\"`\n}\n\n// ListAPIKeys returns all API keys for the authenticated user\nfunc (c *Client) ListAPIKeys(ctx context.Context) ([]APIKey, error) {\n\tvar result apiKeysResponse\n\tif err := c.DoRequest(ctx, \"GET\", TradeAPIPrefix+\"/api-keys\", nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.APIKeys, nil\n}\n\n// APIKey represents an API key\ntype APIKey struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCreatedTime JSONTime `json:\"created_time\"`\n\tExpiresTime JSONTime `json:\"expires_time,omitempty\"`\n\tScopes []string `json:\"scopes\"`\n}\n\ntype apiKeysResponse struct {\n\tAPIKeys []APIKey `json:\"api_keys\"`\n}\n\n// CreateAPIKeyRequest is the request to create an API key\ntype CreateAPIKeyRequest struct {\n\tName string `json:\"name,omitempty\"`\n}\n\n// CreateAPIKeyResponse is the response from creating an API key\ntype CreateAPIKeyResponse struct {\n\tAPIKey APIKey `json:\"api_key\"`\n\tPrivateKey string `json:\"private_key\"`\n}\n\n// CreateAPIKey creates a new API key\nfunc (c *Client) CreateAPIKey(ctx context.Context, req CreateAPIKeyRequest) (*CreateAPIKeyResponse, error) {\n\tvar result CreateAPIKeyResponse\n\tif err := c.DoRequest(ctx, \"POST\", TradeAPIPrefix+\"/api-keys\", req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// DeleteAPIKey deletes an API key by ID\nfunc (c *Client) DeleteAPIKey(ctx context.Context, keyID string) error {\n\treturn c.DoRequest(ctx, \"DELETE\", TradeAPIPrefix+\"/api-keys/\"+keyID, nil, nil)\n}\n\n// APILimitsResponse represents the API rate limits for the account\ntype APILimitsResponse struct {\n\tRateLimit int `json:\"rate_limit\"`\n\tMaxOrdersPerCall int `json:\"max_orders_per_call\"`\n}\n\n// GetAPILimits retrieves the API tier limits for the authenticated user\nfunc (c *Client) GetAPILimits(ctx context.Context) (*APILimitsResponse, error) {\n\tvar result APILimitsResponse\n\tif err := c.DoRequest(ctx, \"GET\", TradeAPIPrefix+\"/account/api-limits\", nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GenerateAPIKeyRequest is the request to generate a new API key pair\ntype GenerateAPIKeyRequest struct {\n\tName string `json:\"name,omitempty\"`\n}\n\n// GenerateAPIKeyResponse is the response from generating an API key pair\ntype GenerateAPIKeyResponse struct {\n\tAPIKey APIKey `json:\"api_key\"`\n\tPrivateKey string `json:\"private_key\"`\n}\n\n// GenerateAPIKey generates a new API key pair (Kalshi generates the key pair)\nfunc (c *Client) GenerateAPIKey(ctx context.Context, req GenerateAPIKeyRequest) (*GenerateAPIKeyResponse, error) {\n\tvar result GenerateAPIKeyResponse\n\tif err := c.DoRequest(ctx, \"POST\", TradeAPIPrefix+\"/api-keys/generate\", req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateAPIKeyWithPublicKeyRequest is the request to create an API key with a user-provided public key\ntype CreateAPIKeyWithPublicKeyRequest struct {\n\tName string `json:\"name,omitempty\"`\n\tPublicKey string `json:\"public_key\"`\n}\n\n// CreateAPIKeyWithPublicKeyResponse is the response from creating an API key with a user-provided public key\ntype CreateAPIKeyWithPublicKeyResponse struct {\n\tAPIKey APIKey `json:\"api_key\"`\n}\n\n// CreateAPIKeyWithPublicKey creates a new API key using a user-provided RSA public key\nfunc (c *Client) CreateAPIKeyWithPublicKey(ctx context.Context, req CreateAPIKeyWithPublicKeyRequest) (*CreateAPIKeyWithPublicKeyResponse, error) {\n\tvar result CreateAPIKeyWithPublicKeyResponse\n\tif err := c.DoRequest(ctx, \"POST\", TradeAPIPrefix+\"/api-keys\", req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// JSONTime is a time.Time that can be unmarshaled from JSON\ntype JSONTime struct {\n\ttime.Time\n}\n\n// UnmarshalJSON implements json.Unmarshaler\nfunc (t *JSONTime) UnmarshalJSON(data []byte) error {\n\ts := string(data)\n\tif s == \"null\" || s == `\"\"` {\n\t\treturn nil\n\t}\n\ts = s[1 : len(s)-1]\n\n\tparsed, err := time.Parse(time.RFC3339, s)\n\tif err != nil {\n\t\tparsed, err = time.Parse(\"2006-01-02T15:04:05Z\", s)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tt.Time = parsed\n\treturn nil\n}\n\n// MarshalJSON implements json.Marshaler\nfunc (t JSONTime) MarshalJSON() ([]byte, error) {\n\tif t.IsZero() {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn []byte(`\"` + t.Format(time.RFC3339) + `\"`), nil\n}\n\n// Format formats the time\nfunc (t JSONTime) Format(layout string) string {\n\treturn t.Time.Format(layout)\n}\n\n// IsZero returns whether the time is zero\nfunc (t JSONTime) IsZero() bool {\n\treturn t.Time.IsZero()\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":15074,"content_sha256":"aabc4eb39a803cfe37f064ec24d92a64119aca4873f39c87808dc192560505ec"},{"filename":"internal/api/communications_paths_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// These tests verify correct API paths according to Kalshi documentation.\n// RFQs and Quotes should be under /communications namespace.\n\nfunc TestCommunicationsPathsAudit(t *testing.T) {\n\t// Define expected paths according to Kalshi API documentation\n\texpectedPaths := map[string]string{\n\t\t\"GetRFQs\": \"/trade-api/v2/communications/rfqs\",\n\t\t\"GetRFQ\": \"/trade-api/v2/communications/rfqs/rfq-123\",\n\t\t\"CreateRFQ\": \"/trade-api/v2/communications/rfqs\",\n\t\t\"DeleteRFQ\": \"/trade-api/v2/communications/rfqs/rfq-123\",\n\t\t\"GetQuotes\": \"/trade-api/v2/communications/quotes\",\n\t\t\"GetQuote\": \"/trade-api/v2/communications/quotes/quote-456\",\n\t\t\"CreateQuote\": \"/trade-api/v2/communications/quotes\",\n\t\t\"AcceptQuote\": \"/trade-api/v2/communications/quotes/quote-456/accept\",\n\t\t\"ConfirmQuote\": \"/trade-api/v2/communications/quotes/quote-456/confirm\",\n\t\t\"DeleteQuote\": \"/trade-api/v2/communications/quotes/quote-456\",\n\t\t\"GetCommID\": \"/trade-api/v2/communications/id\",\n\t}\n\n\tt.Run(\"GetRFQs uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.RFQsResponse{RFQs: []models.RFQ{}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.GetRFQs(context.Background(), RFQsOptions{})\n\n\t\tif actualPath != expectedPaths[\"GetRFQs\"] {\n\t\t\tt.Errorf(\"GetRFQs path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"GetRFQs\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"GetRFQ uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.RFQResponse{RFQ: models.RFQ{\n\t\t\t\tID: \"rfq-123\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.GetRFQ(context.Background(), \"rfq-123\")\n\n\t\tif actualPath != expectedPaths[\"GetRFQ\"] {\n\t\t\tt.Errorf(\"GetRFQ path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"GetRFQ\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"CreateRFQ uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.RFQResponse{RFQ: models.RFQ{\n\t\t\t\tID: \"new-rfq\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.CreateRFQ(context.Background(), models.CreateRFQRequest{\n\t\t\tMarketTicker: \"TEST\",\n\t\t\tContracts: 100,\n\t\t})\n\n\t\tif actualPath != expectedPaths[\"CreateRFQ\"] {\n\t\t\tt.Errorf(\"CreateRFQ path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"CreateRFQ\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"CancelRFQ uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.CancelRFQ(context.Background(), \"rfq-123\")\n\n\t\tif actualPath != expectedPaths[\"DeleteRFQ\"] {\n\t\t\tt.Errorf(\"CancelRFQ path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"DeleteRFQ\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"GetQuotes uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.QuotesResponse{Quotes: []models.Quote{}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.GetQuotes(context.Background(), QuotesOptions{})\n\n\t\tif actualPath != expectedPaths[\"GetQuotes\"] {\n\t\t\tt.Errorf(\"GetQuotes path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"GetQuotes\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"GetQuote uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.QuoteResponse{Quote: models.Quote{\n\t\t\t\tID: \"quote-456\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.GetQuote(context.Background(), \"quote-456\")\n\n\t\tif actualPath != expectedPaths[\"GetQuote\"] {\n\t\t\tt.Errorf(\"GetQuote path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"GetQuote\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"CreateQuote uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.QuoteResponse{Quote: models.Quote{\n\t\t\t\tID: \"new-quote\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.CreateQuote(context.Background(), models.CreateQuoteRequest{\n\t\t\tRFQID: \"rfq-123\",\n\t\t\tYesBid: 50,\n\t\t})\n\n\t\tif actualPath != expectedPaths[\"CreateQuote\"] {\n\t\t\tt.Errorf(\"CreateQuote path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"CreateQuote\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"AcceptQuote uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.QuoteResponse{Quote: models.Quote{\n\t\t\t\tID: \"quote-456\",\n\t\t\t\tStatus: \"accepted\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.AcceptQuote(context.Background(), \"quote-456\")\n\n\t\tif actualPath != expectedPaths[\"AcceptQuote\"] {\n\t\t\tt.Errorf(\"AcceptQuote path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"AcceptQuote\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"ConfirmQuote uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.QuoteResponse{Quote: models.Quote{\n\t\t\t\tID: \"quote-456\",\n\t\t\t\tStatus: \"confirmed\",\n\t\t\t}})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.ConfirmQuote(context.Background(), \"quote-456\")\n\n\t\tif actualPath != expectedPaths[\"ConfirmQuote\"] {\n\t\t\tt.Errorf(\"ConfirmQuote path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"ConfirmQuote\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"CancelQuote uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.CancelQuote(context.Background(), \"quote-456\")\n\n\t\tif actualPath != expectedPaths[\"DeleteQuote\"] {\n\t\t\tt.Errorf(\"CancelQuote path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"DeleteQuote\"], actualPath)\n\t\t}\n\t})\n\n\tt.Run(\"GetCommunicationsID uses correct path\", func(t *testing.T) {\n\t\tvar actualPath string\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tactualPath = r.URL.Path\n\t\t\tjson.NewEncoder(w).Encode(models.CommunicationsIDResponse{CommunicationsID: \"comm-123\"})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createPathTestClient(t, server.URL)\n\t\tclient.GetCommunicationsID(context.Background())\n\n\t\tif actualPath != expectedPaths[\"GetCommID\"] {\n\t\t\tt.Errorf(\"GetCommunicationsID path mismatch:\\n expected: %s\\n actual: %s\", expectedPaths[\"GetCommID\"], actualPath)\n\t\t}\n\t})\n}\n\nfunc TestConfirmQuoteExists(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(models.QuoteResponse{Quote: models.Quote{\n\t\t\tID: \"quote-789\",\n\t\t\tStatus: \"confirmed\",\n\t\t}})\n\t}))\n\tdefer server.Close()\n\n\tclient := createPathTestClient(t, server.URL)\n\tresult, err := client.ConfirmQuote(context.Background(), \"quote-789\")\n\tif err != nil {\n\t\tt.Fatalf(\"ConfirmQuote failed: %v\", err)\n\t}\n\tif result.Quote.Status != \"confirmed\" {\n\t\tt.Errorf(\"expected status 'confirmed', got '%s'\", result.Quote.Status)\n\t}\n}\n\nfunc TestEmptyIDValidation(t *testing.T) {\n\tclient := &Client{}\n\n\tt.Run(\"GetRFQ with empty ID\", func(t *testing.T) {\n\t\t_, err := client.GetRFQ(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"RFQ ID is required\" {\n\t\t\tt.Errorf(\"expected 'RFQ ID is required' error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"CancelRFQ with empty ID\", func(t *testing.T) {\n\t\terr := client.CancelRFQ(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"RFQ ID is required\" {\n\t\t\tt.Errorf(\"expected 'RFQ ID is required' error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"GetQuote with empty ID\", func(t *testing.T) {\n\t\t_, err := client.GetQuote(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"quote ID is required\" {\n\t\t\tt.Errorf(\"expected 'quote ID is required' error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"AcceptQuote with empty ID\", func(t *testing.T) {\n\t\t_, err := client.AcceptQuote(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"quote ID is required\" {\n\t\t\tt.Errorf(\"expected 'quote ID is required' error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"ConfirmQuote with empty ID\", func(t *testing.T) {\n\t\t_, err := client.ConfirmQuote(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"quote ID is required\" {\n\t\t\tt.Errorf(\"expected 'quote ID is required' error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"CancelQuote with empty ID\", func(t *testing.T) {\n\t\terr := client.CancelQuote(context.Background(), \"\")\n\t\tif err == nil || err.Error() != \"quote ID is required\" {\n\t\t\tt.Errorf(\"expected 'quote ID is required' error, got: %v\", err)\n\t\t}\n\t})\n}\n\n// Helper to create a test client with custom base URL\nfunc createPathTestClient(t *testing.T, baseURL string) *Client {\n\tt.Helper()\n\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create signer: %v\", err)\n\t}\n\n\treturn NewClientLegacy(signer, WithBaseURL(baseURL))\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":10710,"content_sha256":"d77973c7ad3aba39d0f5827f7191304b126e979dd01918275b18d16c301216b6"},{"filename":"internal/api/communications_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestGetRFQs(t *testing.T) {\n\texpectedRFQs := []models.RFQ{\n\t\t{\n\t\t\tID: \"rfq-1\",\n\t\t\tMarketTicker: \"BTC-100K\",\n\t\t\tContracts: 100,\n\t\t\tStatus: \"active\",\n\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/rfqs\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.RFQsResponse{\n\t\t\tRFQs: expectedRFQs,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetRFQs(context.Background(), RFQsOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetRFQs failed: %v\", err)\n\t}\n\n\tif len(result.RFQs) != 1 {\n\t\tt.Errorf(\"expected 1 RFQ, got %d\", len(result.RFQs))\n\t}\n\tif result.RFQs[0].ID != \"rfq-1\" {\n\t\tt.Errorf(\"expected RFQ ID 'rfq-1', got '%s'\", result.RFQs[0].ID)\n\t}\n}\n\nfunc TestGetRFQsWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"ticker\") != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", query.Get(\"ticker\"))\n\t\t}\n\t\tif query.Get(\"status\") != \"active\" {\n\t\t\tt.Errorf(\"expected status 'active', got '%s'\", query.Get(\"status\"))\n\t\t}\n\n\t\tresp := models.RFQsResponse{RFQs: []models.RFQ{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetRFQs(context.Background(), RFQsOptions{\n\t\tTicker: \"BTC-100K\",\n\t\tStatus: \"active\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetRFQs failed: %v\", err)\n\t}\n}\n\nfunc TestGetRFQ(t *testing.T) {\n\texpectedRFQ := models.RFQ{\n\t\tID: \"rfq-123\",\n\t\tMarketTicker: \"BTC-100K\",\n\t\tContracts: 50,\n\t\tStatus: \"active\",\n\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/rfqs/rfq-123\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.RFQResponse{RFQ: expectedRFQ}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetRFQ(context.Background(), \"rfq-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetRFQ failed: %v\", err)\n\t}\n\n\tif result.RFQ.ID != \"rfq-123\" {\n\t\tt.Errorf(\"expected RFQ ID 'rfq-123', got '%s'\", result.RFQ.ID)\n\t}\n}\n\nfunc TestCreateRFQ(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/rfqs\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.CreateRFQRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.MarketTicker != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected market_ticker 'BTC-100K', got '%s'\", req.MarketTicker)\n\t\t}\n\t\tif req.Contracts != 100 {\n\t\t\tt.Errorf(\"expected contracts 100, got %d\", req.Contracts)\n\t\t}\n\n\t\tresp := models.RFQResponse{\n\t\t\tRFQ: models.RFQ{\n\t\t\t\tID: \"new-rfq-id\",\n\t\t\t\tMarketTicker: req.MarketTicker,\n\t\t\t\tContracts: req.Contracts,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateRFQ(context.Background(), models.CreateRFQRequest{\n\t\tMarketTicker: \"BTC-100K\",\n\t\tContracts: 100,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"CreateRFQ failed: %v\", err)\n\t}\n\n\tif result.RFQ.ID != \"new-rfq-id\" {\n\t\tt.Errorf(\"expected RFQ ID 'new-rfq-id', got '%s'\", result.RFQ.ID)\n\t}\n}\n\nfunc TestCancelRFQ(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/rfqs/rfq-to-cancel\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\terr := client.CancelRFQ(context.Background(), \"rfq-to-cancel\")\n\tif err != nil {\n\t\tt.Fatalf(\"CancelRFQ failed: %v\", err)\n\t}\n}\n\nfunc TestGetQuotes(t *testing.T) {\n\texpectedQuotes := []models.Quote{\n\t\t{\n\t\t\tID: \"quote-1\",\n\t\t\tRFQID: \"rfq-1\",\n\t\t\tYesBid: 55,\n\t\t\tContracts: 100,\n\t\t\tStatus: \"active\",\n\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/quotes\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.QuotesResponse{\n\t\t\tQuotes: expectedQuotes,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetQuotes(context.Background(), QuotesOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetQuotes failed: %v\", err)\n\t}\n\n\tif len(result.Quotes) != 1 {\n\t\tt.Errorf(\"expected 1 quote, got %d\", len(result.Quotes))\n\t}\n\tif result.Quotes[0].ID != \"quote-1\" {\n\t\tt.Errorf(\"expected quote ID 'quote-1', got '%s'\", result.Quotes[0].ID)\n\t}\n}\n\nfunc TestGetQuotesWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"rfq_id\") != \"rfq-123\" {\n\t\t\tt.Errorf(\"expected rfq_id 'rfq-123', got '%s'\", query.Get(\"rfq_id\"))\n\t\t}\n\t\tif query.Get(\"status\") != \"active\" {\n\t\t\tt.Errorf(\"expected status 'active', got '%s'\", query.Get(\"status\"))\n\t\t}\n\n\t\tresp := models.QuotesResponse{Quotes: []models.Quote{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetQuotes(context.Background(), QuotesOptions{\n\t\tRFQID: \"rfq-123\",\n\t\tStatus: \"active\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetQuotes failed: %v\", err)\n\t}\n}\n\nfunc TestGetQuote(t *testing.T) {\n\texpectedQuote := models.Quote{\n\t\tID: \"quote-123\",\n\t\tRFQID: \"rfq-1\",\n\t\tYesBid: 60,\n\t\tContracts: 50,\n\t\tStatus: \"active\",\n\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/quotes/quote-123\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.QuoteResponse{Quote: expectedQuote}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetQuote(context.Background(), \"quote-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetQuote failed: %v\", err)\n\t}\n\n\tif result.Quote.ID != \"quote-123\" {\n\t\tt.Errorf(\"expected quote ID 'quote-123', got '%s'\", result.Quote.ID)\n\t}\n}\n\nfunc TestCreateQuote(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/quotes\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.CreateQuoteRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.RFQID != \"rfq-123\" {\n\t\t\tt.Errorf(\"expected rfq_id 'rfq-123', got '%s'\", req.RFQID)\n\t\t}\n\t\tif req.YesBid != 55 {\n\t\t\tt.Errorf(\"expected yes_bid 55, got %d\", req.YesBid)\n\t\t}\n\n\t\tresp := models.QuoteResponse{\n\t\t\tQuote: models.Quote{\n\t\t\t\tID: \"new-quote-id\",\n\t\t\t\tRFQID: req.RFQID,\n\t\t\t\tYesBid: req.YesBid,\n\t\t\t\tContracts: 100,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTs: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateQuote(context.Background(), models.CreateQuoteRequest{\n\t\tRFQID: \"rfq-123\",\n\t\tYesBid: 55,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"CreateQuote failed: %v\", err)\n\t}\n\n\tif result.Quote.ID != \"new-quote-id\" {\n\t\tt.Errorf(\"expected quote ID 'new-quote-id', got '%s'\", result.Quote.ID)\n\t}\n}\n\nfunc TestAcceptQuote(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/quotes/quote-123/accept\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.QuoteResponse{\n\t\t\tQuote: models.Quote{\n\t\t\t\tID: \"quote-123\",\n\t\t\t\tStatus: \"accepted\",\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.AcceptQuote(context.Background(), \"quote-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"AcceptQuote failed: %v\", err)\n\t}\n\n\tif result.Quote.Status != \"accepted\" {\n\t\tt.Errorf(\"expected status 'accepted', got '%s'\", result.Quote.Status)\n\t}\n}\n\nfunc TestCancelQuote(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/quotes/quote-to-cancel\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\terr := client.CancelQuote(context.Background(), \"quote-to-cancel\")\n\tif err != nil {\n\t\tt.Fatalf(\"CancelQuote failed: %v\", err)\n\t}\n}\n\nfunc TestGetCommunicationsID(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/communications/id\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.CommunicationsIDResponse{\n\t\t\tCommunicationsID: \"comm-id-123\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetCommunicationsID(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetCommunicationsID failed: %v\", err)\n\t}\n\n\tif result.CommunicationsID != \"comm-id-123\" {\n\t\tt.Errorf(\"expected communications ID 'comm-id-123', got '%s'\", result.CommunicationsID)\n\t}\n}\n\nfunc TestCommunicationsAPIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"error\": \"Invalid request\",\n\t\t\t\"code\": \"INVALID_REQUEST\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.CreateRFQ(context.Background(), models.CreateRFQRequest{})\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid request\")\n\t}\n\n\tapiErr, ok := err.(*APIError)\n\tif !ok {\n\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t}\n\tif apiErr.StatusCode != 400 {\n\t\tt.Errorf(\"expected status 400, got %d\", apiErr.StatusCode)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":12110,"content_sha256":"872bbb6bdfa65e126537f632d24be1cf6e02ab6929c0fa8d36a45f3a5d81de75"},{"filename":"internal/api/communications.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nconst (\n\t// Per Kalshi API docs, RFQs and Quotes are under /communications\n\trfqsBasePath = TradeAPIPrefix + \"/communications/rfqs\"\n\tquotesBasePath = TradeAPIPrefix + \"/communications/quotes\"\n\tcommunicationsBasePath = TradeAPIPrefix + \"/communications\"\n)\n\n// RFQsOptions contains options for listing RFQs\ntype RFQsOptions struct {\n\tTicker string\n\tStatus string\n\tCursor string\n\tLimit int\n}\n\n// toQueryParams converts RFQsOptions to query parameters\nfunc (o RFQsOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Ticker != \"\" {\n\t\tparams[\"ticker\"] = o.Ticker\n\t}\n\tif o.Status != \"\" {\n\t\tparams[\"status\"] = o.Status\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\treturn params\n}\n\n// QuotesOptions contains options for listing quotes\ntype QuotesOptions struct {\n\tRFQID string\n\tStatus string\n\tCursor string\n\tLimit int\n}\n\n// toQueryParams converts QuotesOptions to query parameters\nfunc (o QuotesOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.RFQID != \"\" {\n\t\tparams[\"rfq_id\"] = o.RFQID\n\t}\n\tif o.Status != \"\" {\n\t\tparams[\"status\"] = o.Status\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\treturn params\n}\n\n// GetRFQs returns a list of RFQs based on the provided options\nfunc (c *Client) GetRFQs(ctx context.Context, opts RFQsOptions) (*models.RFQsResponse, error) {\n\tpath := rfqsBasePath + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.RFQsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetRFQ returns a single RFQ by ID\nfunc (c *Client) GetRFQ(ctx context.Context, rfqID string) (*models.RFQResponse, error) {\n\tif rfqID == \"\" {\n\t\treturn nil, fmt.Errorf(\"RFQ ID is required\")\n\t}\n\n\tpath := rfqsBasePath + \"/\" + rfqID\n\n\tvar result models.RFQResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateRFQ creates a new RFQ\nfunc (c *Client) CreateRFQ(ctx context.Context, req models.CreateRFQRequest) (*models.RFQResponse, error) {\n\tvar result models.RFQResponse\n\tif err := c.PostJSON(ctx, rfqsBasePath, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CancelRFQ cancels an existing RFQ\nfunc (c *Client) CancelRFQ(ctx context.Context, rfqID string) error {\n\tif rfqID == \"\" {\n\t\treturn fmt.Errorf(\"RFQ ID is required\")\n\t}\n\n\tpath := rfqsBasePath + \"/\" + rfqID\n\treturn c.DeleteJSON(ctx, path, nil)\n}\n\n// GetQuotes returns a list of quotes based on the provided options\nfunc (c *Client) GetQuotes(ctx context.Context, opts QuotesOptions) (*models.QuotesResponse, error) {\n\tpath := quotesBasePath + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.QuotesResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetQuote returns a single quote by ID\nfunc (c *Client) GetQuote(ctx context.Context, quoteID string) (*models.QuoteResponse, error) {\n\tif quoteID == \"\" {\n\t\treturn nil, fmt.Errorf(\"quote ID is required\")\n\t}\n\n\tpath := quotesBasePath + \"/\" + quoteID\n\n\tvar result models.QuoteResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateQuote creates a new quote on an RFQ\nfunc (c *Client) CreateQuote(ctx context.Context, req models.CreateQuoteRequest) (*models.QuoteResponse, error) {\n\tvar result models.QuoteResponse\n\tif err := c.PostJSON(ctx, quotesBasePath, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// AcceptQuote accepts a quote\nfunc (c *Client) AcceptQuote(ctx context.Context, quoteID string) (*models.QuoteResponse, error) {\n\tif quoteID == \"\" {\n\t\treturn nil, fmt.Errorf(\"quote ID is required\")\n\t}\n\n\tpath := quotesBasePath + \"/\" + quoteID + \"/accept\"\n\n\tvar result models.QuoteResponse\n\tif err := c.PostJSON(ctx, path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CancelQuote cancels an existing quote\nfunc (c *Client) CancelQuote(ctx context.Context, quoteID string) error {\n\tif quoteID == \"\" {\n\t\treturn fmt.Errorf(\"quote ID is required\")\n\t}\n\n\tpath := quotesBasePath + \"/\" + quoteID\n\treturn c.DeleteJSON(ctx, path, nil)\n}\n\n// ConfirmQuote confirms a quote (quoter confirms their own quote after RFQ creator accepts)\nfunc (c *Client) ConfirmQuote(ctx context.Context, quoteID string) (*models.QuoteResponse, error) {\n\tif quoteID == \"\" {\n\t\treturn nil, fmt.Errorf(\"quote ID is required\")\n\t}\n\n\tpath := quotesBasePath + \"/\" + quoteID + \"/confirm\"\n\n\tvar result models.QuoteResponse\n\tif err := c.PostJSON(ctx, path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetCommunicationsID returns the user's communications ID for websocket subscriptions\nfunc (c *Client) GetCommunicationsID(ctx context.Context) (*models.CommunicationsIDResponse, error) {\n\tpath := communicationsBasePath + \"/id\"\n\n\tvar result models.CommunicationsIDResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5245,"content_sha256":"a9a7b88c3cb0ba5d01e7cd81452cbde7e2b16b6a2ecb10e966b28c01327cedd4"},{"filename":"internal/api/events_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestListEvents(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListEventsParams\n\t\tserverResponse models.EventsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t\twantCursor string\n\t}{\n\t\t{\n\t\t\tname: \"returns events successfully\",\n\t\t\tparams: ListEventsParams{},\n\t\t\tserverResponse: models.EventsResponse{\n\t\t\t\tEvents: []models.Event{\n\t\t\t\t\t{EventTicker: \"ELECTION-2024\", Title: \"2024 Presidential Election\"},\n\t\t\t\t\t{EventTicker: \"FED-MAR-2024\", Title: \"March 2024 Fed Decision\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor-123\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t\twantCursor: \"next-cursor-123\",\n\t\t},\n\t\t{\n\t\t\tname: \"returns events with status filter\",\n\t\t\tparams: ListEventsParams{Status: \"open\"},\n\t\t\tserverResponse: models.EventsResponse{\n\t\t\t\tEvents: []models.Event{\n\t\t\t\t\t{EventTicker: \"ELECTION-2024\", Title: \"2024 Presidential Election\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t\twantCursor: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"returns events with pagination\",\n\t\t\tparams: ListEventsParams{Cursor: \"prev-cursor\", Limit: 10},\n\t\t\tserverResponse: models.EventsResponse{\n\t\t\t\tEvents: []models.Event{\n\t\t\t\t\t{EventTicker: \"EVENT-1\", Title: \"Event 1\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t\twantCursor: \"next-cursor\",\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListEventsParams{},\n\t\t\tserverResponse: models.EventsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Status != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"status\"); got != tt.params.Status {\n\t\t\t\t\t\tt.Errorf(\"expected status=%s, got %s\", tt.params.Status, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Cursor != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"cursor\"); got != tt.params.Cursor {\n\t\t\t\t\t\tt.Errorf(\"expected cursor=%s, got %s\", tt.params.Cursor, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tevents, cursor, err := client.ListEvents(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(events) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d events, got %d\", tt.wantCount, len(events))\n\t\t\t}\n\n\t\t\tif cursor != tt.wantCursor {\n\t\t\t\tt.Errorf(\"expected cursor %q, got %q\", tt.wantCursor, cursor)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetEvent(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\teventTicker string\n\t\tserverResponse models.EventResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantTicker string\n\t}{\n\t\t{\n\t\t\tname: \"returns single event successfully\",\n\t\t\teventTicker: \"ELECTION-2024\",\n\t\t\tserverResponse: models.EventResponse{\n\t\t\t\tEvent: models.Event{\n\t\t\t\t\tEventTicker: \"ELECTION-2024\",\n\t\t\t\t\tTitle: \"2024 Presidential Election\",\n\t\t\t\t\tCategory: \"politics\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantTicker: \"ELECTION-2024\",\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\teventTicker: \"INVALID-EVENT\",\n\t\t\tserverResponse: models.EventResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns event with markets list\",\n\t\t\teventTicker: \"FED-MAR-2024\",\n\t\t\tserverResponse: models.EventResponse{\n\t\t\t\tEvent: models.Event{\n\t\t\t\t\tEventTicker: \"FED-MAR-2024\",\n\t\t\t\t\tTitle: \"March 2024 Fed Decision\",\n\t\t\t\t\tMarkets: []string{\"FED-RATE-25\", \"FED-RATE-50\", \"FED-RATE-HOLD\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantTicker: \"FED-MAR-2024\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/events/\" + tt.eventTicker\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetEvent(context.Background(), tt.eventTicker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.EventTicker != tt.wantTicker {\n\t\t\t\tt.Errorf(\"expected event ticker %q, got %q\", tt.wantTicker, resp.EventTicker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestListMultivariateEvents(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListMultivariateParams\n\t\tserverResponse models.MultivariateEventsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns multivariate events successfully\",\n\t\t\tparams: ListMultivariateParams{},\n\t\t\tserverResponse: models.MultivariateEventsResponse{\n\t\t\t\tEvents: []models.MultivariateEvent{\n\t\t\t\t\t{Ticker: \"MV-EVENT-1\", Title: \"Multivariate Event 1\"},\n\t\t\t\t\t{Ticker: \"MV-EVENT-2\", Title: \"Multivariate Event 2\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns multivariate events with pagination\",\n\t\t\tparams: ListMultivariateParams{Cursor: \"cursor-123\", Limit: 10},\n\t\t\tserverResponse: models.MultivariateEventsResponse{\n\t\t\t\tEvents: []models.MultivariateEvent{\n\t\t\t\t\t{Ticker: \"MV-EVENT-3\", Title: \"Multivariate Event 3\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"returns multivariate events with status filter\",\n\t\t\tparams: ListMultivariateParams{Status: \"open\"},\n\t\t\tserverResponse: models.MultivariateEventsResponse{\n\t\t\t\tEvents: []models.MultivariateEvent{\n\t\t\t\t\t{Ticker: \"MV-EVENT-4\", Title: \"Multivariate Event 4\", Status: \"open\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListMultivariateParams{},\n\t\t\tserverResponse: models.MultivariateEventsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Status != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"status\"); got != tt.params.Status {\n\t\t\t\t\t\tt.Errorf(\"expected status=%s, got %s\", tt.params.Status, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tevents, _, err := client.ListMultivariateEvents(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(events) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d multivariate events, got %d\", tt.wantCount, len(events))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetMultivariateEvent(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse MultivariateEventResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns multivariate event successfully\",\n\t\t\tticker: \"MV-EVENT-1\",\n\t\t\tserverResponse: MultivariateEventResponse{\n\t\t\t\tEvent: models.MultivariateEvent{\n\t\t\t\t\tTicker: \"MV-EVENT-1\",\n\t\t\t\t\tTitle: \"Multivariate Event 1\",\n\t\t\t\t\tStatus: \"open\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID-MV-EVENT\",\n\t\t\tserverResponse: MultivariateEventResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/events/multivariate/\" + tt.ticker\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetMultivariateEvent(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Ticker != tt.ticker {\n\t\t\t\tt.Errorf(\"expected ticker %q, got %q\", tt.ticker, resp.Ticker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetEventCandlesticks(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams CandlesticksParams\n\t\trawResponse string // raw JSON in Kalshi v2 event candlestick format\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns candlesticks successfully\",\n\t\t\tparams: CandlesticksParams{\n\t\t\t\tSeriesTicker: \"ELECTION-SERIES\",\n\t\t\t\tTicker: \"ELECTION-2024\",\n\t\t\t\tPeriod: \"1h\",\n\t\t\t},\n\t\t\trawResponse: `{\n\t\t\t\t\"market_tickers\": [\"ELECTION-2024\"],\n\t\t\t\t\"market_candlesticks\": [[\n\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 50, \"high\": 55, \"low\": 48, \"close\": 52}, \"volume\": 1000, \"open_interest\": 500},\n\t\t\t\t\t{\"end_period_ts\": 1704070800, \"price\": {\"open\": 52, \"high\": 58, \"low\": 51, \"close\": 56}, \"volume\": 1200, \"open_interest\": 550}\n\t\t\t\t]]\n\t\t\t}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns candlesticks with time range\",\n\t\t\tparams: CandlesticksParams{\n\t\t\t\tSeriesTicker: \"FED-SERIES\",\n\t\t\t\tTicker: \"FED-MAR-2024\",\n\t\t\t\tPeriod: \"15m\",\n\t\t\t\tStartTime: func() *time.Time { t := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC); return &t }(),\n\t\t\t\tEndTime: func() *time.Time { t := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC); return &t }(),\n\t\t\t},\n\t\t\trawResponse: `{\n\t\t\t\t\"market_tickers\": [\"FED-MAR-2024\"],\n\t\t\t\t\"market_candlesticks\": [[\n\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 45, \"high\": 50, \"low\": 44, \"close\": 49}, \"volume\": 800, \"open_interest\": 400}\n\t\t\t\t]]\n\t\t\t}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty candlesticks for no data\",\n\t\t\tparams: CandlesticksParams{\n\t\t\t\tSeriesTicker: \"NO-DATA-SERIES\",\n\t\t\t\tTicker: \"NO-DATA-EVENT\",\n\t\t\t\tPeriod: \"1d\",\n\t\t\t},\n\t\t\trawResponse: `{\"market_tickers\": [], \"market_candlesticks\": []}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found error\",\n\t\t\tparams: CandlesticksParams{\n\t\t\t\tSeriesTicker: \"INVALID-SERIES\",\n\t\t\t\tTicker: \"INVALID-EVENT\",\n\t\t\t\tPeriod: \"1h\",\n\t\t\t},\n\t\t\trawResponse: `{}`,\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: CandlesticksParams{\n\t\t\t\tSeriesTicker: \"ERROR-SERIES\",\n\t\t\t\tTicker: \"ERROR-EVENT\",\n\t\t\t\tPeriod: \"1h\",\n\t\t\t},\n\t\t\trawResponse: `{}`,\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/series/\" + tt.params.SeriesTicker + \"/events/\" + tt.params.Ticker + \"/candlesticks\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Period != \"\" {\n\t\t\t\t\texpectedInterval := periodToInterval(tt.params.Period)\n\t\t\t\t\tif got := r.URL.Query().Get(\"period_interval\"); got != expectedInterval {\n\t\t\t\t\t\tt.Errorf(\"expected period_interval=%s, got %s\", expectedInterval, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.StartTime != nil {\n\t\t\t\t\tif got := r.URL.Query().Get(\"start_ts\"); got == \"\" {\n\t\t\t\t\t\tt.Error(\"expected start_ts query param\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.EndTime != nil {\n\t\t\t\t\tif got := r.URL.Query().Get(\"end_ts\"); got == \"\" {\n\t\t\t\t\t\tt.Error(\"expected end_ts query param\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tw.Write([]byte(tt.rawResponse))\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tcandlesticks, err := client.GetEventCandlesticks(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(candlesticks) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d candlesticks, got %d\", tt.wantCount, len(candlesticks))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetEventMetadata(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse models.EventMetadataResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns event metadata successfully\",\n\t\t\tticker: \"ELECTION-2024\",\n\t\t\tserverResponse: models.EventMetadataResponse{\n\t\t\t\tEventMetadata: models.EventMetadata{\n\t\t\t\t\tEventTicker: \"ELECTION-2024\",\n\t\t\t\t\tMetadata: map[string]string{\n\t\t\t\t\t\t\"source\": \"AP News\",\n\t\t\t\t\t\t\"resolution\": \"Official election results\",\n\t\t\t\t\t\t\"last_update\": \"2024-11-05T00:00:00Z\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID-EVENT\",\n\t\t\tserverResponse: models.EventMetadataResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tticker: \"ERROR-EVENT\",\n\t\t\tserverResponse: models.EventMetadataResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/events/\" + tt.ticker + \"/metadata\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetEventMetadata(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.EventTicker != tt.ticker {\n\t\t\t\tt.Errorf(\"expected event ticker %q, got %q\", tt.ticker, resp.EventTicker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetForecastPercentileHistory(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ForecastPercentileHistoryParams\n\t\tserverResponse models.ForecastPercentileHistoryResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns forecast history successfully\",\n\t\t\tparams: ForecastPercentileHistoryParams{\n\t\t\t\tTicker: \"ELECTION-2024\",\n\t\t\t},\n\t\t\tserverResponse: models.ForecastPercentileHistoryResponse{\n\t\t\t\tHistory: []models.ForecastPercentilePoint{\n\t\t\t\t\t{Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), P10: 40, P25: 45, P50: 50, P75: 55, P90: 60},\n\t\t\t\t\t{Timestamp: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), P10: 42, P25: 47, P50: 52, P75: 57, P90: 62},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns forecast history with time range\",\n\t\t\tparams: ForecastPercentileHistoryParams{\n\t\t\t\tTicker: \"FED-MAR-2024\",\n\t\t\t\tStartTime: func() *time.Time { t := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC); return &t }(),\n\t\t\t\tEndTime: func() *time.Time { t := time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC); return &t }(),\n\t\t\t},\n\t\t\tserverResponse: models.ForecastPercentileHistoryResponse{\n\t\t\t\tHistory: []models.ForecastPercentilePoint{\n\t\t\t\t\t{Timestamp: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC), P10: 35, P25: 40, P50: 45, P75: 50, P90: 55},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tparams: ForecastPercentileHistoryParams{\n\t\t\t\tTicker: \"INVALID-EVENT\",\n\t\t\t},\n\t\t\tserverResponse: models.ForecastPercentileHistoryResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ForecastPercentileHistoryParams{\n\t\t\t\tTicker: \"ERROR-EVENT\",\n\t\t\t},\n\t\t\tserverResponse: models.ForecastPercentileHistoryResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/events/\" + tt.params.Ticker + \"/forecast-percentile-history\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.StartTime != nil {\n\t\t\t\t\tif got := r.URL.Query().Get(\"start_ts\"); got == \"\" {\n\t\t\t\t\t\tt.Error(\"expected start_ts query param\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.EndTime != nil {\n\t\t\t\t\tif got := r.URL.Query().Get(\"end_ts\"); got == \"\" {\n\t\t\t\t\t\tt.Error(\"expected end_ts query param\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\thistory, err := client.GetForecastPercentileHistory(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(history) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d history points, got %d\", tt.wantCount, len(history))\n\t\t\t}\n\t\t})\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":19804,"content_sha256":"63a0186f8e21ab98d422b74501e246cf5cf1b6a14a3a3a4715903bfc51009bc8"},{"filename":"internal/api/events.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// ListEventsParams contains parameters for listing events\ntype ListEventsParams struct {\n\tStatus string\n\tLimit int\n\tCursor string\n}\n\n// CandlesticksParams contains parameters for getting candlesticks\ntype CandlesticksParams struct {\n\tSeriesTicker string\n\tTicker string\n\tPeriod string\n\tStartTime *time.Time\n\tEndTime *time.Time\n}\n\n// ListMultivariateParams contains parameters for listing multivariate events\ntype ListMultivariateParams struct {\n\tStatus string\n\tLimit int\n\tCursor string\n}\n\n// ForecastPercentileHistoryParams contains parameters for getting forecast history\ntype ForecastPercentileHistoryParams struct {\n\tTicker string\n\tStartTime *time.Time\n\tEndTime *time.Time\n}\n\n// ListEvents retrieves a list of events with optional filtering\nfunc (c *Client) ListEvents(ctx context.Context, params ListEventsParams) ([]models.Event, string, error) {\n\tqueryParams := make(map[string]string)\n\n\tif params.Status != \"\" {\n\t\tqueryParams[\"status\"] = params.Status\n\t}\n\tif params.Limit > 0 {\n\t\tqueryParams[\"limit\"] = strconv.Itoa(params.Limit)\n\t}\n\tif params.Cursor != \"\" {\n\t\tqueryParams[\"cursor\"] = params.Cursor\n\t}\n\n\tpath := TradeAPIPrefix + \"/events\" + BuildQueryString(queryParams)\n\n\tvar resp models.EventsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn resp.Events, resp.Cursor, nil\n}\n\n// GetEvent retrieves a single event by ticker\nfunc (c *Client) GetEvent(ctx context.Context, ticker string) (*models.Event, error) {\n\tpath := TradeAPIPrefix + \"/events/\" + ticker\n\n\tvar resp models.EventResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &resp.Event, nil\n}\n\n// GetEventCandlesticks retrieves candlestick data for an event.\n// The event endpoint returns market_tickers + market_candlesticks (array of arrays).\n// This method flattens all markets' candlesticks into a single slice.\nfunc (c *Client) GetEventCandlesticks(ctx context.Context, params CandlesticksParams) ([]models.Candlestick, error) {\n\tqueryParams := make(map[string]string)\n\n\tif params.Period != \"\" {\n\t\tqueryParams[\"period_interval\"] = periodToInterval(params.Period)\n\t}\n\tif params.StartTime != nil {\n\t\tqueryParams[\"start_ts\"] = strconv.FormatInt(params.StartTime.Unix(), 10)\n\t}\n\tif params.EndTime != nil {\n\t\tqueryParams[\"end_ts\"] = strconv.FormatInt(params.EndTime.Unix(), 10)\n\t}\n\n\tpath := TradeAPIPrefix + \"/series/\" + params.SeriesTicker + \"/events/\" + params.Ticker + \"/candlesticks\" + BuildQueryString(queryParams)\n\n\tvar resp models.EventCandlesticksResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn resp.AllCandlesticks(), nil\n}\n\n// ListMultivariateEvents retrieves a list of multivariate events\nfunc (c *Client) ListMultivariateEvents(ctx context.Context, params ListMultivariateParams) ([]models.MultivariateEvent, string, error) {\n\tqueryParams := make(map[string]string)\n\n\tif params.Status != \"\" {\n\t\tqueryParams[\"status\"] = params.Status\n\t}\n\tif params.Limit > 0 {\n\t\tqueryParams[\"limit\"] = strconv.Itoa(params.Limit)\n\t}\n\tif params.Cursor != \"\" {\n\t\tqueryParams[\"cursor\"] = params.Cursor\n\t}\n\n\tpath := TradeAPIPrefix + \"/events/multivariate\" + BuildQueryString(queryParams)\n\n\tvar resp models.MultivariateEventsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn resp.Events, resp.Cursor, nil\n}\n\n// MultivariateEventResponse is the API response for a single multivariate event\ntype MultivariateEventResponse struct {\n\tEvent models.MultivariateEvent `json:\"multivariate_event\"`\n}\n\n// GetMultivariateEvent retrieves a single multivariate event by ticker\nfunc (c *Client) GetMultivariateEvent(ctx context.Context, ticker string) (*models.MultivariateEvent, error) {\n\tpath := TradeAPIPrefix + \"/events/multivariate/\" + ticker\n\n\tvar resp MultivariateEventResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &resp.Event, nil\n}\n\n// GetEventMetadata retrieves metadata for a specific event\nfunc (c *Client) GetEventMetadata(ctx context.Context, ticker string) (*models.EventMetadata, error) {\n\tpath := TradeAPIPrefix + \"/events/\" + ticker + \"/metadata\"\n\n\tvar resp models.EventMetadataResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &resp.EventMetadata, nil\n}\n\n// GetForecastPercentileHistory retrieves historical forecast percentile data for an event\nfunc (c *Client) GetForecastPercentileHistory(ctx context.Context, params ForecastPercentileHistoryParams) ([]models.ForecastPercentilePoint, error) {\n\tqueryParams := make(map[string]string)\n\n\tif params.StartTime != nil {\n\t\tqueryParams[\"start_ts\"] = strconv.FormatInt(params.StartTime.Unix(), 10)\n\t}\n\tif params.EndTime != nil {\n\t\tqueryParams[\"end_ts\"] = strconv.FormatInt(params.EndTime.Unix(), 10)\n\t}\n\n\tpath := TradeAPIPrefix + \"/events/\" + params.Ticker + \"/forecast-percentile-history\" + BuildQueryString(queryParams)\n\n\tvar resp models.ForecastPercentileHistoryResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn resp.History, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5275,"content_sha256":"fcb8fd14a1f36627bf21abd8f74024beaf7ef35255500ed4e78a994b0fed5083"},{"filename":"internal/api/exchange_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestGetExchangeStatus(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse ExchangeStatusResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantActive bool\n\t\twantTrading bool\n\t}{\n\t\t{\n\t\t\tname: \"returns exchange status - all active\",\n\t\t\tserverResponse: ExchangeStatusResponse{\n\t\t\t\tExchangeActive: true,\n\t\t\t\tTradingActive: true,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantActive: true,\n\t\t\twantTrading: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns exchange status - maintenance\",\n\t\t\tserverResponse: ExchangeStatusResponse{\n\t\t\t\tExchangeActive: true,\n\t\t\t\tTradingActive: false,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantActive: true,\n\t\t\twantTrading: false,\n\t\t},\n\t\t{\n\t\t\tname: \"returns exchange status - offline\",\n\t\t\tserverResponse: ExchangeStatusResponse{\n\t\t\t\tExchangeActive: false,\n\t\t\t\tTradingActive: false,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantActive: false,\n\t\t\twantTrading: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: ExchangeStatusResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetExchangeStatus(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.ExchangeActive != tt.wantActive {\n\t\t\t\tt.Errorf(\"expected exchange_active=%v, got %v\", tt.wantActive, resp.ExchangeActive)\n\t\t\t}\n\n\t\t\tif resp.TradingActive != tt.wantTrading {\n\t\t\t\tt.Errorf(\"expected trading_active=%v, got %v\", tt.wantTrading, resp.TradingActive)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetExchangeSchedule(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.ExchangeScheduleResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns schedule successfully\",\n\t\t\tserverResponse: models.ExchangeScheduleResponse{\n\t\t\t\tSchedule: models.ExchangeSchedule{\n\t\t\t\t\tStandardHours: []models.WeeklySchedule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStartTime: \"2024-01-15\",\n\t\t\t\t\t\t\tEndTime: \"2024-06-15\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMaintenanceWindows: []models.MaintenanceWindow{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStartDatetime: \"2024-01-20T02:00:00Z\",\n\t\t\t\t\t\t\tEndDatetime: \"2024-01-20T04:00:00Z\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty schedule\",\n\t\t\tserverResponse: models.ExchangeScheduleResponse{\n\t\t\t\tSchedule: models.ExchangeSchedule{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.ExchangeScheduleResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/exchange/schedule\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetExchangeSchedule(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp == nil {\n\t\t\t\tt.Fatal(\"expected non-nil response\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetAnnouncements(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.AnnouncementsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns announcements successfully\",\n\t\t\tserverResponse: models.AnnouncementsResponse{\n\t\t\t\tAnnouncements: []models.Announcement{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"ann-1\",\n\t\t\t\t\t\tTitle: \"Scheduled Maintenance\",\n\t\t\t\t\t\tMessage: \"The exchange will be down for maintenance.\",\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t\tType: \"maintenance\",\n\t\t\t\t\t\tCreatedTime: now,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"ann-2\",\n\t\t\t\t\t\tTitle: \"New Markets Available\",\n\t\t\t\t\t\tMessage: \"We have added new crypto markets.\",\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t\tType: \"info\",\n\t\t\t\t\t\tCreatedTime: now,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty announcements\",\n\t\t\tserverResponse: models.AnnouncementsResponse{\n\t\t\t\tAnnouncements: []models.Announcement{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.AnnouncementsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/exchange/announcements\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetAnnouncements(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Announcements) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d announcements, got %d\", tt.wantCount, len(resp.Announcements))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetSeriesFeeChanges(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.SeriesFeeChangesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns series fee changes successfully\",\n\t\t\tserverResponse: models.SeriesFeeChangesResponse{\n\t\t\t\tSeriesFeeChanges: []models.SeriesFeeChange{\n\t\t\t\t\t{\n\t\t\t\t\t\tSeriesTicker: \"PRES\",\n\t\t\t\t\t\tOldFeeRate: 0.05,\n\t\t\t\t\t\tNewFeeRate: 0.03,\n\t\t\t\t\t\tEffectiveDate: now.Add(24 * time.Hour),\n\t\t\t\t\t\tAnnouncedDate: now,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty series fee changes\",\n\t\t\tserverResponse: models.SeriesFeeChangesResponse{\n\t\t\t\tSeriesFeeChanges: []models.SeriesFeeChange{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.SeriesFeeChangesResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/exchange/series-fee-changes\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetSeriesFeeChanges(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.SeriesFeeChanges) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d series fee changes, got %d\", tt.wantCount, len(resp.SeriesFeeChanges))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetUserDataTimestamp(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.UserDataTimestampResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns user data timestamp successfully\",\n\t\t\tserverResponse: models.UserDataTimestampResponse{\n\t\t\t\tTimestamp: now,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.UserDataTimestampResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized error\",\n\t\t\tserverResponse: models.UserDataTimestampResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/exchange/user-data-timestamp\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetUserDataTimestamp(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Timestamp.IsZero() {\n\t\t\t\tt.Error(\"expected non-zero timestamp\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetExchangeStatusUsesCorrectPath(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\texpectedPath := TradeAPIPrefix + \"/exchange/status\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(ExchangeStatusResponse{\n\t\t\tExchangeActive: true,\n\t\t\tTradingActive: true,\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := newTestClient(t, server.URL)\n\t_, err := client.GetExchangeStatus(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":11560,"content_sha256":"4ca03e70404e1d1f79523d93a6c5d14a36793f7826d9d342168748f8b431a839"},{"filename":"internal/api/exchange.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// GetExchangeSchedule retrieves the exchange schedule\nfunc (c *Client) GetExchangeSchedule(ctx context.Context) (*models.ExchangeScheduleResponse, error) {\n\tpath := TradeAPIPrefix + \"/exchange/schedule\"\n\n\tvar result models.ExchangeScheduleResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get exchange schedule: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetAnnouncements retrieves the exchange announcements\nfunc (c *Client) GetAnnouncements(ctx context.Context) (*models.AnnouncementsResponse, error) {\n\tpath := TradeAPIPrefix + \"/exchange/announcements\"\n\n\tvar result models.AnnouncementsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get announcements: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetFeeChanges retrieves the upcoming fee changes\n// Deprecated: Use GetSeriesFeeChanges instead - this uses the wrong API path\nfunc (c *Client) GetFeeChanges(ctx context.Context) (*models.FeeChangesResponse, error) {\n\tpath := TradeAPIPrefix + \"/exchange/fee-changes\"\n\n\tvar result models.FeeChangesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get fee changes: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetSeriesFeeChanges retrieves the upcoming series fee changes per Kalshi API spec\nfunc (c *Client) GetSeriesFeeChanges(ctx context.Context) (*models.SeriesFeeChangesResponse, error) {\n\tpath := TradeAPIPrefix + \"/exchange/series-fee-changes\"\n\n\tvar result models.SeriesFeeChangesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get series fee changes: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetUserDataTimestamp retrieves the timestamp indicating when user data was last updated\nfunc (c *Client) GetUserDataTimestamp(ctx context.Context) (*models.UserDataTimestampResponse, error) {\n\tpath := TradeAPIPrefix + \"/exchange/user-data-timestamp\"\n\n\tvar result models.UserDataTimestampResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get user data timestamp: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2308,"content_sha256":"9eea9389a03d45203dd350115540868d0384c3e2490deef8125185d011e0de43"},{"filename":"internal/api/markets_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestListMarkets(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListMarketsParams\n\t\tserverResponse models.MarketsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t\twantCursor string\n\t}{\n\t\t{\n\t\t\tname: \"returns markets successfully\",\n\t\t\tparams: ListMarketsParams{},\n\t\t\tserverResponse: models.MarketsResponse{\n\t\t\t\tMarkets: []models.Market{\n\t\t\t\t\t{Ticker: \"BTC-100K\", Title: \"Bitcoin to $100K\"},\n\t\t\t\t\t{Ticker: \"ETH-10K\", Title: \"Ethereum to $10K\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor-123\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t\twantCursor: \"next-cursor-123\",\n\t\t},\n\t\t{\n\t\t\tname: \"returns markets with status filter\",\n\t\t\tparams: ListMarketsParams{Status: \"open\"},\n\t\t\tserverResponse: models.MarketsResponse{\n\t\t\t\tMarkets: []models.Market{\n\t\t\t\t\t{Ticker: \"BTC-100K\", Title: \"Bitcoin to $100K\", Status: \"open\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t\twantCursor: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"returns markets with pagination\",\n\t\t\tparams: ListMarketsParams{Cursor: \"prev-cursor\", Limit: 10},\n\t\t\tserverResponse: models.MarketsResponse{\n\t\t\t\tMarkets: []models.Market{\n\t\t\t\t\t{Ticker: \"MARKET-1\", Title: \"Market 1\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t\twantCursor: \"next-cursor\",\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListMarketsParams{},\n\t\t\tserverResponse: models.MarketsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns markets with series ticker filter\",\n\t\t\tparams: ListMarketsParams{SeriesTicker: \"FED-RATES\"},\n\t\t\tserverResponse: models.MarketsResponse{\n\t\t\t\tMarkets: []models.Market{\n\t\t\t\t\t{Ticker: \"FED-RATE-MAR\", Title: \"March Rate Decision\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Status != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"status\"); got != tt.params.Status {\n\t\t\t\t\t\tt.Errorf(\"expected status=%s, got %s\", tt.params.Status, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.SeriesTicker != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"series_ticker\"); got != tt.params.SeriesTicker {\n\t\t\t\t\t\tt.Errorf(\"expected series_ticker=%s, got %s\", tt.params.SeriesTicker, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Cursor != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"cursor\"); got != tt.params.Cursor {\n\t\t\t\t\t\tt.Errorf(\"expected cursor=%s, got %s\", tt.params.Cursor, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.ListMarkets(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Markets) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d markets, got %d\", tt.wantCount, len(resp.Markets))\n\t\t\t}\n\n\t\t\tif resp.Cursor != tt.wantCursor {\n\t\t\t\tt.Errorf(\"expected cursor %q, got %q\", tt.wantCursor, resp.Cursor)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetMarket(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse models.MarketResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantTicker string\n\t}{\n\t\t{\n\t\t\tname: \"returns single market successfully\",\n\t\t\tticker: \"BTC-100K\",\n\t\t\tserverResponse: models.MarketResponse{\n\t\t\t\tMarket: models.Market{\n\t\t\t\t\tTicker: \"BTC-100K\",\n\t\t\t\t\tTitle: \"Bitcoin to $100K\",\n\t\t\t\t\tStatus: \"open\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantTicker: \"BTC-100K\",\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID-TICKER\",\n\t\t\tserverResponse: models.MarketResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns market with all fields\",\n\t\t\tticker: \"ETH-5K\",\n\t\t\tserverResponse: models.MarketResponse{\n\t\t\t\tMarket: models.Market{\n\t\t\t\t\tTicker: \"ETH-5K\",\n\t\t\t\t\tEventTicker: \"ETH-PRICE\",\n\t\t\t\t\tTitle: \"Ethereum to $5K\",\n\t\t\t\t\tStatus: \"open\",\n\t\t\t\t\tYesBid: 45,\n\t\t\t\t\tYesAsk: 47,\n\t\t\t\t\tVolume: 10000,\n\t\t\t\t\tOpenInterest: 5000,\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantTicker: \"ETH-5K\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/markets/\" + tt.ticker\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetMarket(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Ticker != tt.wantTicker {\n\t\t\t\tt.Errorf(\"expected ticker %q, got %q\", tt.wantTicker, resp.Ticker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetOrderbook(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse models.OrderbookResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns orderbook successfully\",\n\t\t\tticker: \"BTC-100K\",\n\t\t\tserverResponse: models.OrderbookResponse{\n\t\t\t\tOrderbook: models.Orderbook{\n\t\t\t\t\tTicker: \"BTC-100K\",\n\t\t\t\t\tYesBids: []models.OrderbookLevel{\n\t\t\t\t\t\t{Price: 45, Quantity: 100},\n\t\t\t\t\t\t{Price: 44, Quantity: 200},\n\t\t\t\t\t},\n\t\t\t\t\tYesAsks: []models.OrderbookLevel{\n\t\t\t\t\t\t{Price: 47, Quantity: 150},\n\t\t\t\t\t\t{Price: 48, Quantity: 250},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles invalid ticker\",\n\t\t\tticker: \"INVALID\",\n\t\t\tserverResponse: models.OrderbookResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty orderbook\",\n\t\t\tticker: \"ETH-5K\",\n\t\t\tserverResponse: models.OrderbookResponse{\n\t\t\t\tOrderbook: models.Orderbook{\n\t\t\t\t\tTicker: \"ETH-5K\",\n\t\t\t\t\tYesBids: []models.OrderbookLevel{},\n\t\t\t\t\tYesAsks: []models.OrderbookLevel{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/markets/\" + tt.ticker + \"/orderbook\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetOrderbook(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Ticker != tt.ticker {\n\t\t\t\tt.Errorf(\"expected ticker %q, got %q\", tt.ticker, resp.Ticker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetTrades(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams GetTradesParams\n\t\tserverResponse models.TradesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns trades successfully\",\n\t\t\tparams: GetTradesParams{Ticker: \"BTC-100K\"},\n\t\t\tserverResponse: models.TradesResponse{\n\t\t\t\tTrades: []models.Trade{\n\t\t\t\t\t{TradeID: \"trade-1\", Ticker: \"BTC-100K\", Price: 45, Count: 10},\n\t\t\t\t\t{TradeID: \"trade-2\", Ticker: \"BTC-100K\", Price: 46, Count: 5},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns trades with pagination\",\n\t\t\tparams: GetTradesParams{Cursor: \"cursor-123\", Limit: 50},\n\t\t\tserverResponse: models.TradesResponse{\n\t\t\t\tTrades: []models.Trade{\n\t\t\t\t\t{TradeID: \"trade-3\", Ticker: \"ETH-5K\", Price: 30, Count: 20},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: GetTradesParams{},\n\t\t\tserverResponse: models.TradesResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Ticker != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"ticker\"); got != tt.params.Ticker {\n\t\t\t\t\t\tt.Errorf(\"expected ticker=%s, got %s\", tt.params.Ticker, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetTrades(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Trades) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d trades, got %d\", tt.wantCount, len(resp.Trades))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetCandlesticks(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams GetCandlesticksParams\n\t\trawResponse string // raw JSON in Kalshi v2 format\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns candlesticks successfully\",\n\t\t\tparams: GetCandlesticksParams{SeriesTicker: \"BTC-SERIES\", Ticker: \"BTC-100K\", Period: \"1h\"},\n\t\t\trawResponse: `{\n\t\t\t\t\"ticker\": \"BTC-100K\",\n\t\t\t\t\"candlesticks\": [\n\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 45, \"high\": 48, \"low\": 44, \"close\": 47}, \"volume\": 1000, \"open_interest\": 0},\n\t\t\t\t\t{\"end_period_ts\": 1704070800, \"price\": {\"open\": 47, \"high\": 50, \"low\": 46, \"close\": 49}, \"volume\": 1200, \"open_interest\": 0}\n\t\t\t\t]\n\t\t\t}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"handles invalid ticker\",\n\t\t\tparams: GetCandlesticksParams{SeriesTicker: \"INVALID-SERIES\", Ticker: \"INVALID\", Period: \"1h\"},\n\t\t\trawResponse: `{}`,\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"returns candlesticks with time range\",\n\t\t\tparams: GetCandlesticksParams{\n\t\t\t\tSeriesTicker: \"ETH-SERIES\",\n\t\t\t\tTicker: \"ETH-5K\",\n\t\t\t\tPeriod: \"1d\",\n\t\t\t\tStartTime: time.Now().Add(-24 * time.Hour).Unix(),\n\t\t\t\tEndTime: time.Now().Unix(),\n\t\t\t},\n\t\t\trawResponse: `{\n\t\t\t\t\"ticker\": \"ETH-5K\",\n\t\t\t\t\"candlesticks\": [\n\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 30, \"high\": 32, \"low\": 29, \"close\": 31}, \"volume\": 0, \"open_interest\": 0}\n\t\t\t\t]\n\t\t\t}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/series/\" + tt.params.SeriesTicker + \"/markets/\" + tt.params.Ticker + \"/candlesticks\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tw.Write([]byte(tt.rawResponse))\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetCandlesticks(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Candlesticks) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d candlesticks, got %d\", tt.wantCount, len(resp.Candlesticks))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestListSeries(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListSeriesParams\n\t\tserverResponse models.SeriesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns series successfully\",\n\t\t\tparams: ListSeriesParams{},\n\t\t\tserverResponse: models.SeriesResponse{\n\t\t\t\tSeries: []models.Series{\n\t\t\t\t\t{Ticker: \"FED-RATES\", Title: \"Federal Reserve Rate Decisions\"},\n\t\t\t\t\t{Ticker: \"BTC-PRICE\", Title: \"Bitcoin Price Milestones\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns series with cursor pagination\",\n\t\t\tparams: ListSeriesParams{Cursor: \"cursor-123\"},\n\t\t\tserverResponse: models.SeriesResponse{\n\t\t\t\tSeries: []models.Series{\n\t\t\t\t\t{Ticker: \"ETH-PRICE\", Title: \"Ethereum Price Milestones\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"returns series with category filter\",\n\t\t\tparams: ListSeriesParams{Category: \"crypto\"},\n\t\t\tserverResponse: models.SeriesResponse{\n\t\t\t\tSeries: []models.Series{\n\t\t\t\t\t{Ticker: \"BTC-PRICE\", Title: \"Bitcoin Price Milestones\", Category: \"crypto\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListSeriesParams{},\n\t\t\tserverResponse: models.SeriesResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Category != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"category\"); got != tt.params.Category {\n\t\t\t\t\t\tt.Errorf(\"expected category=%s, got %s\", tt.params.Category, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.ListSeries(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Series) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d series, got %d\", tt.wantCount, len(resp.Series))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetSeries(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse GetSeriesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns series by ticker successfully\",\n\t\t\tticker: \"FED-RATES\",\n\t\t\tserverResponse: GetSeriesResponse{\n\t\t\t\tSeries: models.Series{\n\t\t\t\t\tTicker: \"FED-RATES\",\n\t\t\t\t\tTitle: \"Federal Reserve Rate Decisions\",\n\t\t\t\t\tCategory: \"economics\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID-SERIES\",\n\t\t\tserverResponse: GetSeriesResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/series/\" + tt.ticker\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetSeries(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Ticker != tt.ticker {\n\t\t\t\tt.Errorf(\"expected ticker %q, got %q\", tt.ticker, resp.Ticker)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// newTestClient creates a test client with a mock server URL\nfunc newTestClient(t *testing.T, serverURL string) *Client {\n\tt.Helper()\n\tsigner := newTestSigner(t)\n\treturn NewClientLegacy(signer, WithBaseURL(serverURL))\n}\n\n// newTestSigner creates a test signer with a generated key\nfunc newTestSigner(t *testing.T) *Signer {\n\tt.Helper()\n\tkey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\tsigner, err := NewSigner(\"test-api-key-id\", key)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create signer: %v\", err)\n\t}\n\treturn signer\n}\n\n// TDD RED: Tests for missing ListMarketsParams fields (min_close_ts, max_close_ts, event_ticker, tickers)\nfunc TestListMarkets_WithTimeFilters(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListMarketsParams\n\t\twantQueryKey string\n\t\twantQueryVal string\n\t}{\n\t\t{\n\t\t\tname: \"filters by min_close_ts\",\n\t\t\tparams: ListMarketsParams{\n\t\t\t\tMinCloseTs: 1704067200,\n\t\t\t},\n\t\t\twantQueryKey: \"min_close_ts\",\n\t\t\twantQueryVal: \"1704067200\",\n\t\t},\n\t\t{\n\t\t\tname: \"filters by max_close_ts\",\n\t\t\tparams: ListMarketsParams{\n\t\t\t\tMaxCloseTs: 1704153600,\n\t\t\t},\n\t\t\twantQueryKey: \"max_close_ts\",\n\t\t\twantQueryVal: \"1704153600\",\n\t\t},\n\t\t{\n\t\t\tname: \"filters by event_ticker\",\n\t\t\tparams: ListMarketsParams{\n\t\t\t\tEventTicker: \"PRES-2024\",\n\t\t\t},\n\t\t\twantQueryKey: \"event_ticker\",\n\t\t\twantQueryVal: \"PRES-2024\",\n\t\t},\n\t\t{\n\t\t\tname: \"filters by multiple tickers\",\n\t\t\tparams: ListMarketsParams{\n\t\t\t\tTickers: []string{\"BTC-100K\", \"ETH-10K\"},\n\t\t\t},\n\t\t\twantQueryKey: \"tickers\",\n\t\t\twantQueryVal: \"BTC-100K,ETH-10K\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tgot := r.URL.Query().Get(tt.wantQueryKey)\n\t\t\t\tif got != tt.wantQueryVal {\n\t\t\t\t\tt.Errorf(\"expected %s=%s, got %s\", tt.wantQueryKey, tt.wantQueryVal, got)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(models.MarketsResponse{\n\t\t\t\t\tMarkets: []models.Market{},\n\t\t\t\t\tCursor: \"\",\n\t\t\t\t})\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\t_, err := client.ListMarkets(context.Background(), tt.params)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TDD RED: Test for orderbook depth parameter\nfunc TestGetOrderbook_WithDepth(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tdepth int\n\t\twantDepth string\n\t}{\n\t\t{\n\t\t\tname: \"requests orderbook with depth 5\",\n\t\t\tticker: \"BTC-100K\",\n\t\t\tdepth: 5,\n\t\t\twantDepth: \"5\",\n\t\t},\n\t\t{\n\t\t\tname: \"requests orderbook with depth 10\",\n\t\t\tticker: \"ETH-5K\",\n\t\t\tdepth: 10,\n\t\t\twantDepth: \"10\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tgot := r.URL.Query().Get(\"depth\")\n\t\t\t\tif got != tt.wantDepth {\n\t\t\t\t\tt.Errorf(\"expected depth=%s, got %s\", tt.wantDepth, got)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(models.OrderbookResponse{\n\t\t\t\t\tOrderbook: models.Orderbook{\n\t\t\t\t\t\tTicker: tt.ticker,\n\t\t\t\t\t\tYesBids: []models.OrderbookLevel{},\n\t\t\t\t\t\tYesAsks: []models.OrderbookLevel{},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\t_, err := client.GetOrderbookWithDepth(context.Background(), tt.ticker, tt.depth)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TDD RED: Test for trades with timestamp filters\nfunc TestGetTrades_WithTimeFilters(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams GetTradesParams\n\t\twantQueryKey string\n\t\twantQueryVal string\n\t}{\n\t\t{\n\t\t\tname: \"filters by min_ts\",\n\t\t\tparams: GetTradesParams{\n\t\t\t\tTicker: \"BTC-100K\",\n\t\t\t\tMinTs: 1704067200,\n\t\t\t},\n\t\t\twantQueryKey: \"min_ts\",\n\t\t\twantQueryVal: \"1704067200\",\n\t\t},\n\t\t{\n\t\t\tname: \"filters by max_ts\",\n\t\t\tparams: GetTradesParams{\n\t\t\t\tTicker: \"BTC-100K\",\n\t\t\t\tMaxTs: 1704153600,\n\t\t\t},\n\t\t\twantQueryKey: \"max_ts\",\n\t\t\twantQueryVal: \"1704153600\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tgot := r.URL.Query().Get(tt.wantQueryKey)\n\t\t\t\tif got != tt.wantQueryVal {\n\t\t\t\t\tt.Errorf(\"expected %s=%s, got %s\", tt.wantQueryKey, tt.wantQueryVal, got)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(models.TradesResponse{\n\t\t\t\t\tTrades: []models.Trade{},\n\t\t\t\t\tCursor: \"\",\n\t\t\t\t})\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\t_, err := client.GetTrades(context.Background(), tt.params)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TDD RED: Test for batch candlesticks endpoint\nfunc TestGetBatchCandlesticks(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams GetBatchCandlesticksParams\n\t\trawResponse string // raw JSON in Kalshi v2 format\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns batch candlesticks for multiple tickers\",\n\t\t\tparams: GetBatchCandlesticksParams{\n\t\t\t\tTickers: []string{\"BTC-100K\", \"ETH-5K\", \"SOL-500\"},\n\t\t\t\tPeriod: \"1h\",\n\t\t\t},\n\t\t\trawResponse: `{\n\t\t\t\t\"market_candlesticks\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"ticker\": \"BTC-100K\",\n\t\t\t\t\t\t\"candlesticks\": [\n\t\t\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 45, \"high\": 48, \"low\": 44, \"close\": 47}, \"volume\": 0, \"open_interest\": 0}\n\t\t\t\t\t\t]\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"ticker\": \"ETH-5K\",\n\t\t\t\t\t\t\"candlesticks\": [\n\t\t\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 30, \"high\": 32, \"low\": 29, \"close\": 31}, \"volume\": 0, \"open_interest\": 0}\n\t\t\t\t\t\t]\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"ticker\": \"SOL-500\",\n\t\t\t\t\t\t\"candlesticks\": [\n\t\t\t\t\t\t\t{\"end_period_ts\": 1704067200, \"price\": {\"open\": 55, \"high\": 58, \"low\": 54, \"close\": 57}, \"volume\": 0, \"open_interest\": 0}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}`,\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 3,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: GetBatchCandlesticksParams{\n\t\t\t\tTickers: []string{\"INVALID\"},\n\t\t\t\tPeriod: \"1h\",\n\t\t\t},\n\t\t\trawResponse: `{}`,\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/markets/candlesticks/batch\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\t// Verify tickers query param\n\t\t\t\ttickersParam := r.URL.Query().Get(\"tickers\")\n\t\t\t\tif tt.params.Tickers != nil && len(tt.params.Tickers) > 0 && len(tt.params.Tickers) \u003c= 100 {\n\t\t\t\t\texpectedTickers := strings.Join(tt.params.Tickers, \",\")\n\t\t\t\t\tif tickersParam != expectedTickers {\n\t\t\t\t\t\tt.Errorf(\"expected tickers=%s, got %s\", expectedTickers, tickersParam)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tw.Write([]byte(tt.rawResponse))\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := newTestClient(t, server.URL)\n\t\t\tresp, err := client.GetBatchCandlesticks(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.MarketCandlesticks) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d market candlesticks, got %d\", tt.wantCount, len(resp.MarketCandlesticks))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestGetBatchCandlesticks_MaxTickersLimit tests client-side validation of ticker limit\nfunc TestGetBatchCandlesticks_MaxTickersLimit(t *testing.T) {\n\t// Generate 101 tickers to exceed limit\n\ttickers := make([]string, 101)\n\tfor i := range tickers {\n\t\ttickers[i] = \"TICKER-\" + strconv.Itoa(i)\n\t}\n\n\tclient := newTestClient(t, \"http://localhost:9999\") // URL doesn't matter, will fail before request\n\t_, err := client.GetBatchCandlesticks(context.Background(), GetBatchCandlesticksParams{\n\t\tTickers: tickers,\n\t\tPeriod: \"1h\",\n\t})\n\n\tif err == nil {\n\t\tt.Error(\"expected error for exceeding max tickers, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"exceeds maximum\") {\n\t\tt.Errorf(\"expected error message about exceeding maximum, got: %v\", err)\n\t}\n}\n\n// TDD RED: Test periodToInterval function covers all documented periods\nfunc TestPeriodToInterval(t *testing.T) {\n\ttests := []struct {\n\t\tperiod string\n\t\texpected string\n\t}{\n\t\t{\"1m\", \"1\"},\n\t\t{\"5m\", \"5m\"},\n\t\t{\"15m\", \"15m\"},\n\t\t{\"1h\", \"60\"},\n\t\t{\"4h\", \"4h\"},\n\t\t{\"1d\", \"1440\"},\n\t\t{\"unknown\", \"unknown\"}, // passthrough for unknown periods\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.period, func(t *testing.T) {\n\t\t\tresult := periodToInterval(tt.period)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"periodToInterval(%q) = %q, want %q\", tt.period, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":26916,"content_sha256":"ede6029423f3d3d831c820801512f8f421e45952e2db4b018b964894663dca2f"},{"filename":"internal/api/markets.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// ListMarketsParams contains parameters for listing markets\ntype ListMarketsParams struct {\n\tStatus string\n\tSeriesTicker string\n\tEventTicker string\n\tTickers []string\n\tMinCloseTs int64\n\tMaxCloseTs int64\n\tLimit int\n\tCursor string\n}\n\n// ListMarkets retrieves a list of markets\nfunc (c *Client) ListMarkets(ctx context.Context, params ListMarketsParams) (*models.MarketsResponse, error) {\n\tqueryParams := map[string]string{}\n\n\tif params.Status != \"\" {\n\t\tqueryParams[\"status\"] = params.Status\n\t}\n\tif params.SeriesTicker != \"\" {\n\t\tqueryParams[\"series_ticker\"] = params.SeriesTicker\n\t}\n\tif params.EventTicker != \"\" {\n\t\tqueryParams[\"event_ticker\"] = params.EventTicker\n\t}\n\tif len(params.Tickers) > 0 {\n\t\tqueryParams[\"tickers\"] = strings.Join(params.Tickers, \",\")\n\t}\n\tif params.MinCloseTs > 0 {\n\t\tqueryParams[\"min_close_ts\"] = strconv.FormatInt(params.MinCloseTs, 10)\n\t}\n\tif params.MaxCloseTs > 0 {\n\t\tqueryParams[\"max_close_ts\"] = strconv.FormatInt(params.MaxCloseTs, 10)\n\t}\n\tif params.Limit > 0 {\n\t\tqueryParams[\"limit\"] = strconv.Itoa(params.Limit)\n\t}\n\tif params.Cursor != \"\" {\n\t\tqueryParams[\"cursor\"] = params.Cursor\n\t}\n\n\tpath := TradeAPIPrefix + \"/markets\" + BuildQueryString(queryParams)\n\n\tvar result models.MarketsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list markets: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetMarket retrieves a single market by ticker\nfunc (c *Client) GetMarket(ctx context.Context, ticker string) (*models.Market, error) {\n\tpath := TradeAPIPrefix + \"/markets/\" + ticker\n\n\tvar result models.MarketResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get market: %w\", err)\n\t}\n\n\treturn &result.Market, nil\n}\n\n// GetOrderbook retrieves the orderbook for a market\nfunc (c *Client) GetOrderbook(ctx context.Context, ticker string) (*models.Orderbook, error) {\n\tpath := TradeAPIPrefix + \"/markets/\" + ticker + \"/orderbook\"\n\n\tvar result models.OrderbookResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get orderbook: %w\", err)\n\t}\n\n\treturn &result.Orderbook, nil\n}\n\n// GetOrderbookWithDepth retrieves the orderbook for a market with a specific depth\nfunc (c *Client) GetOrderbookWithDepth(ctx context.Context, ticker string, depth int) (*models.Orderbook, error) {\n\tqueryParams := map[string]string{}\n\tif depth > 0 {\n\t\tqueryParams[\"depth\"] = strconv.Itoa(depth)\n\t}\n\n\tpath := TradeAPIPrefix + \"/markets/\" + ticker + \"/orderbook\" + BuildQueryString(queryParams)\n\n\tvar result models.OrderbookResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get orderbook: %w\", err)\n\t}\n\n\treturn &result.Orderbook, nil\n}\n\n// GetTradesParams contains parameters for getting trades\ntype GetTradesParams struct {\n\tTicker string\n\tLimit int\n\tCursor string\n\tMinTs int64\n\tMaxTs int64\n}\n\n// GetTrades retrieves trades for a market\nfunc (c *Client) GetTrades(ctx context.Context, params GetTradesParams) (*models.TradesResponse, error) {\n\tqueryParams := map[string]string{}\n\n\tif params.Ticker != \"\" {\n\t\tqueryParams[\"ticker\"] = params.Ticker\n\t}\n\tif params.Limit > 0 {\n\t\tqueryParams[\"limit\"] = strconv.Itoa(params.Limit)\n\t}\n\tif params.Cursor != \"\" {\n\t\tqueryParams[\"cursor\"] = params.Cursor\n\t}\n\tif params.MinTs > 0 {\n\t\tqueryParams[\"min_ts\"] = strconv.FormatInt(params.MinTs, 10)\n\t}\n\tif params.MaxTs > 0 {\n\t\tqueryParams[\"max_ts\"] = strconv.FormatInt(params.MaxTs, 10)\n\t}\n\n\tpath := TradeAPIPrefix + \"/markets/trades\" + BuildQueryString(queryParams)\n\n\tvar result models.TradesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get trades: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetCandlesticksParams contains parameters for getting candlesticks\ntype GetCandlesticksParams struct {\n\tSeriesTicker string\n\tTicker string\n\tPeriod string\n\tStartTime int64\n\tEndTime int64\n}\n\n// GetCandlesticks retrieves candlestick data for a market\nfunc (c *Client) GetCandlesticks(ctx context.Context, params GetCandlesticksParams) (*models.CandlesticksResponse, error) {\n\tqueryParams := map[string]string{}\n\n\tif params.Period != \"\" {\n\t\tqueryParams[\"period_interval\"] = periodToInterval(params.Period)\n\t}\n\tif params.StartTime > 0 {\n\t\tqueryParams[\"start_ts\"] = strconv.FormatInt(params.StartTime, 10)\n\t}\n\tif params.EndTime > 0 {\n\t\tqueryParams[\"end_ts\"] = strconv.FormatInt(params.EndTime, 10)\n\t}\n\n\tpath := TradeAPIPrefix + \"/series/\" + params.SeriesTicker + \"/markets/\" + params.Ticker + \"/candlesticks\" + BuildQueryString(queryParams)\n\n\tvar result models.CandlesticksResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get candlesticks: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// periodToInterval converts user-friendly period strings to API intervals\nfunc periodToInterval(period string) string {\n\tswitch period {\n\tcase \"1m\":\n\t\treturn \"1\"\n\tcase \"1h\":\n\t\treturn \"60\"\n\tcase \"1d\":\n\t\treturn \"1440\"\n\tdefault:\n\t\treturn period\n\t}\n}\n\n// ListSeriesParams contains parameters for listing series\ntype ListSeriesParams struct {\n\tCategory string\n\tLimit int\n\tCursor string\n}\n\n// ListSeries retrieves a list of series\nfunc (c *Client) ListSeries(ctx context.Context, params ListSeriesParams) (*models.SeriesResponse, error) {\n\tqueryParams := map[string]string{}\n\n\tif params.Category != \"\" {\n\t\tqueryParams[\"category\"] = params.Category\n\t}\n\tif params.Limit > 0 {\n\t\tqueryParams[\"limit\"] = strconv.Itoa(params.Limit)\n\t}\n\tif params.Cursor != \"\" {\n\t\tqueryParams[\"cursor\"] = params.Cursor\n\t}\n\n\tpath := TradeAPIPrefix + \"/series\" + BuildQueryString(queryParams)\n\n\tvar result models.SeriesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list series: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetSeriesResponse is the API response for a single series\ntype GetSeriesResponse struct {\n\tSeries models.Series `json:\"series\"`\n}\n\n// GetSeries retrieves a single series by ticker\nfunc (c *Client) GetSeries(ctx context.Context, ticker string) (*models.Series, error) {\n\tpath := TradeAPIPrefix + \"/series/\" + ticker\n\n\tvar result GetSeriesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get series: %w\", err)\n\t}\n\n\treturn &result.Series, nil\n}\n\n// GetBatchCandlesticksParams contains parameters for getting batch candlesticks\ntype GetBatchCandlesticksParams struct {\n\tTickers []string\n\tPeriod string\n\tStartTime int64\n\tEndTime int64\n}\n\n// MaxBatchCandlesticksTickers is the maximum number of tickers allowed in batch request\nconst MaxBatchCandlesticksTickers = 100\n\n// GetBatchCandlesticks retrieves candlestick data for multiple markets (up to 100 tickers)\nfunc (c *Client) GetBatchCandlesticks(ctx context.Context, params GetBatchCandlesticksParams) (*models.BatchCandlesticksResponse, error) {\n\tif len(params.Tickers) > MaxBatchCandlesticksTickers {\n\t\treturn nil, fmt.Errorf(\"batch candlesticks request exceeds maximum of %d tickers\", MaxBatchCandlesticksTickers)\n\t}\n\n\tqueryParams := map[string]string{}\n\n\tif len(params.Tickers) > 0 {\n\t\tqueryParams[\"tickers\"] = strings.Join(params.Tickers, \",\")\n\t}\n\tif params.Period != \"\" {\n\t\tqueryParams[\"period_interval\"] = periodToInterval(params.Period)\n\t}\n\tif params.StartTime > 0 {\n\t\tqueryParams[\"start_ts\"] = strconv.FormatInt(params.StartTime, 10)\n\t}\n\tif params.EndTime > 0 {\n\t\tqueryParams[\"end_ts\"] = strconv.FormatInt(params.EndTime, 10)\n\t}\n\n\tpath := TradeAPIPrefix + \"/markets/candlesticks/batch\" + BuildQueryString(queryParams)\n\n\tvar result models.BatchCandlesticksResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get batch candlesticks: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":8004,"content_sha256":"e3c551c72fb79de99252b5e3424af4eee6af083e2d1e6e559baa55f92fedf0ba"},{"filename":"internal/api/milestones_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 1: Write FAILING tests FIRST (RED)\n// =============================================================================\n\nfunc TestGetMilestone(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tmilestoneID string\n\t\tserverResponse models.MilestoneResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns milestone successfully\",\n\t\t\tmilestoneID: \"milestone-123\",\n\t\t\tserverResponse: models.MilestoneResponse{\n\t\t\t\tMilestone: models.Milestone{\n\t\t\t\t\tID: \"milestone-123\",\n\t\t\t\t\tTitle: \"Q1 2024 GDP Report\",\n\t\t\t\t\tDescription: \"Quarterly GDP growth rate\",\n\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\tCategory: \"economics\",\n\t\t\t\t\tTargetDate: now.Add(30 * 24 * time.Hour),\n\t\t\t\t\tCreatedTime: now.Add(-7 * 24 * time.Hour),\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tmilestoneID: \"invalid-id\",\n\t\t\tserverResponse: models.MilestoneResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tmilestoneID: \"milestone-error\",\n\t\t\tserverResponse: models.MilestoneResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/milestones/\" + tt.milestoneID\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := milestonesTestClient(t, server.URL)\n\t\t\tresp, err := client.GetMilestone(context.Background(), tt.milestoneID)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.ID != tt.milestoneID {\n\t\t\t\tt.Errorf(\"expected milestone ID %q, got %q\", tt.milestoneID, resp.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestListMilestones(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tparams ListMilestonesParams\n\t\tserverResponse models.MilestonesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns milestones successfully\",\n\t\t\tparams: ListMilestonesParams{},\n\t\t\tserverResponse: models.MilestonesResponse{\n\t\t\t\tMilestones: []models.Milestone{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"milestone-1\",\n\t\t\t\t\t\tTitle: \"GDP Report\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"milestone-2\",\n\t\t\t\t\t\tTitle: \"Jobs Report\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns milestones with date filter\",\n\t\t\tparams: ListMilestonesParams{\n\t\t\t\tMinDate: now.Add(-30 * 24 * time.Hour),\n\t\t\t\tMaxDate: now,\n\t\t\t},\n\t\t\tserverResponse: models.MilestonesResponse{\n\t\t\t\tMilestones: []models.Milestone{\n\t\t\t\t\t{ID: \"milestone-recent\", Title: \"Recent Milestone\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListMilestonesParams{},\n\t\t\tserverResponse: models.MilestonesResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/milestones\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := milestonesTestClient(t, server.URL)\n\t\t\tresp, err := client.ListMilestones(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Milestones) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d milestones, got %d\", tt.wantCount, len(resp.Milestones))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLiveData(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tmilestoneID string\n\t\tserverResponse models.LiveDataResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns live data successfully\",\n\t\t\tmilestoneID: \"milestone-123\",\n\t\t\tserverResponse: models.LiveDataResponse{\n\t\t\t\tData: models.LiveData{\n\t\t\t\t\tMilestoneID: \"milestone-123\",\n\t\t\t\t\tValue: 3.2,\n\t\t\t\t\tUnit: \"percent\",\n\t\t\t\t\tSource: \"BEA\",\n\t\t\t\t\tTimestamp: now,\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tmilestoneID: \"invalid-id\",\n\t\t\tserverResponse: models.LiveDataResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/live-data/\" + tt.milestoneID\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := milestonesTestClient(t, server.URL)\n\t\t\tresp, err := client.GetLiveData(context.Background(), tt.milestoneID)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.MilestoneID != tt.milestoneID {\n\t\t\t\tt.Errorf(\"expected milestone ID %q, got %q\", tt.milestoneID, resp.MilestoneID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetBatchLiveData(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tmilestoneIDs []string\n\t\tserverResponse models.BatchLiveDataResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns batch live data successfully\",\n\t\t\tmilestoneIDs: []string{\"milestone-1\", \"milestone-2\"},\n\t\t\tserverResponse: models.BatchLiveDataResponse{\n\t\t\t\tData: []models.LiveData{\n\t\t\t\t\t{MilestoneID: \"milestone-1\", Value: 3.2, Timestamp: now},\n\t\t\t\t\t{MilestoneID: \"milestone-2\", Value: 4.5, Timestamp: now},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"handles empty request\",\n\t\t\tmilestoneIDs: []string{},\n\t\t\tserverResponse: models.BatchLiveDataResponse{Data: []models.LiveData{}},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tmilestoneIDs: []string{\"error-id\"},\n\t\t\tserverResponse: models.BatchLiveDataResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/live-data\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := milestonesTestClient(t, server.URL)\n\t\t\tresp, err := client.GetBatchLiveData(context.Background(), tt.milestoneIDs)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Data) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d data items, got %d\", tt.wantCount, len(resp.Data))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// milestonesTestClient creates a test client for milestones tests\nfunc milestonesTestClient(t *testing.T, serverURL string) *Client {\n\tt.Helper()\n\tclient := NewClient(nil, nil)\n\tclient.SetBaseURL(serverURL)\n\treturn client\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":9274,"content_sha256":"f5c8c35771ec235f5a8fae1ccaca1e30aabc413a96af92f1ecd8697c5a682283"},{"filename":"internal/api/milestones.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 2: Implement to make tests pass (GREEN)\n// =============================================================================\n\n// ListMilestonesParams contains parameters for listing milestones\ntype ListMilestonesParams struct {\n\tMinDate time.Time\n\tMaxDate time.Time\n\tLimit int\n\tCursor string\n}\n\n// toQueryParams converts ListMilestonesParams to query parameters\nfunc (p ListMilestonesParams) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif !p.MinDate.IsZero() {\n\t\tparams[\"min_date\"] = p.MinDate.Format(\"2006-01-02\")\n\t}\n\tif !p.MaxDate.IsZero() {\n\t\tparams[\"max_date\"] = p.MaxDate.Format(\"2006-01-02\")\n\t}\n\tif p.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(p.Limit)\n\t}\n\tif p.Cursor != \"\" {\n\t\tparams[\"cursor\"] = p.Cursor\n\t}\n\treturn params\n}\n\n// GetMilestone retrieves a single milestone by ID\nfunc (c *Client) GetMilestone(ctx context.Context, milestoneID string) (*models.Milestone, error) {\n\tif milestoneID == \"\" {\n\t\treturn nil, fmt.Errorf(\"milestone_id is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/milestones/\" + milestoneID\n\n\tvar result models.MilestoneResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get milestone: %w\", err)\n\t}\n\n\treturn &result.Milestone, nil\n}\n\n// ListMilestones retrieves milestones with optional date filtering\nfunc (c *Client) ListMilestones(ctx context.Context, params ListMilestonesParams) (*models.MilestonesResponse, error) {\n\tpath := TradeAPIPrefix + \"/milestones\" + BuildQueryString(params.toQueryParams())\n\n\tvar result models.MilestonesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list milestones: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetLiveData retrieves current data for a specific milestone\nfunc (c *Client) GetLiveData(ctx context.Context, milestoneID string) (*models.LiveData, error) {\n\tif milestoneID == \"\" {\n\t\treturn nil, fmt.Errorf(\"milestone_id is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/live-data/\" + milestoneID\n\n\tvar result models.LiveDataResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get live data: %w\", err)\n\t}\n\n\treturn &result.Data, nil\n}\n\n// GetBatchLiveData retrieves current data for multiple milestones\nfunc (c *Client) GetBatchLiveData(ctx context.Context, milestoneIDs []string) (*models.BatchLiveDataResponse, error) {\n\tparams := make(map[string]string)\n\tif len(milestoneIDs) > 0 {\n\t\tparams[\"milestone_ids\"] = strings.Join(milestoneIDs, \",\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/live-data\" + BuildQueryString(params)\n\n\tvar result models.BatchLiveDataResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get batch live data: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3022,"content_sha256":"3a21c6e81f521e605a67c319eed81adb5fdde96cbcca5e0e18ba77145f9096cc"},{"filename":"internal/api/multivariate_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 1: Write FAILING tests FIRST (RED)\n// =============================================================================\n\nfunc TestListMultivariateCollections(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListMultivariateCollectionsParams\n\t\tserverResponse models.MultivariateCollectionsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns collections successfully\",\n\t\t\tparams: ListMultivariateCollectionsParams{},\n\t\t\tserverResponse: models.MultivariateCollectionsResponse{\n\t\t\t\tCollections: []models.MultivariateCollection{\n\t\t\t\t\t{\n\t\t\t\t\t\tTicker: \"PRES-2024\",\n\t\t\t\t\t\tTitle: \"2024 Presidential Election\",\n\t\t\t\t\t\tDescription: \"Who will win the 2024 US Presidential Election?\",\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tTicker: \"SUPERBOWL-LIX\",\n\t\t\t\t\t\tTitle: \"Super Bowl LIX Winner\",\n\t\t\t\t\t\tDescription: \"Which team will win Super Bowl LIX?\",\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor-123\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns collections with status filter\",\n\t\t\tparams: ListMultivariateCollectionsParams{Status: \"active\"},\n\t\t\tserverResponse: models.MultivariateCollectionsResponse{\n\t\t\t\tCollections: []models.MultivariateCollection{\n\t\t\t\t\t{\n\t\t\t\t\t\tTicker: \"ACTIVE-COLLECTION\",\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCursor: \"\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"returns collections with pagination\",\n\t\t\tparams: ListMultivariateCollectionsParams{Cursor: \"prev-cursor\", Limit: 10},\n\t\t\tserverResponse: models.MultivariateCollectionsResponse{\n\t\t\t\tCollections: []models.MultivariateCollection{\n\t\t\t\t\t{Ticker: \"COLLECTION-PAGE-2\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListMultivariateCollectionsParams{},\n\t\t\tserverResponse: models.MultivariateCollectionsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles unauthorized\",\n\t\t\tparams: ListMultivariateCollectionsParams{},\n\t\t\tserverResponse: models.MultivariateCollectionsResponse{},\n\t\t\tserverStatus: http.StatusUnauthorized,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/multivariate-collections\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Status != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"status\"); got != tt.params.Status {\n\t\t\t\t\t\tt.Errorf(\"expected status=%s, got %s\", tt.params.Status, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.params.Cursor != \"\" {\n\t\t\t\t\tif got := r.URL.Query().Get(\"cursor\"); got != tt.params.Cursor {\n\t\t\t\t\t\tt.Errorf(\"expected cursor=%s, got %s\", tt.params.Cursor, got)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := multivariateTestClient(t, server.URL)\n\t\t\tresp, err := client.ListMultivariateCollections(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Collections) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d collections, got %d\", tt.wantCount, len(resp.Collections))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetMultivariateCollection(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tserverResponse models.MultivariateCollectionResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns collection successfully\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\tserverResponse: models.MultivariateCollectionResponse{\n\t\t\t\tCollection: models.MultivariateCollection{\n\t\t\t\t\tTicker: \"PRES-2024\",\n\t\t\t\t\tTitle: \"2024 Presidential Election\",\n\t\t\t\t\tDescription: \"Who will win the 2024 US Presidential Election?\",\n\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\tLookupType: \"candidate\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID-COLLECTION\",\n\t\t\tserverResponse: models.MultivariateCollectionResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tticker: \"ERROR-COLLECTION\",\n\t\t\tserverResponse: models.MultivariateCollectionResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/multivariate-collections/\" + tt.ticker\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := multivariateTestClient(t, server.URL)\n\t\t\tresp, err := client.GetMultivariateCollection(context.Background(), tt.ticker)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.Ticker != tt.ticker {\n\t\t\t\tt.Errorf(\"expected ticker %q, got %q\", tt.ticker, resp.Ticker)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetCollectionLookupHistory(t *testing.T) {\n\tnow := time.Now().UTC()\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tparams LookupHistoryParams\n\t\tserverResponse models.LookupHistoryResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns lookup history successfully\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\tparams: LookupHistoryParams{},\n\t\t\tserverResponse: models.LookupHistoryResponse{\n\t\t\t\tHistory: []models.LookupHistoryEntry{\n\t\t\t\t\t{\n\t\t\t\t\t\tTicker: \"PRES-2024-TRUMP\",\n\t\t\t\t\t\tLookupValue: \"Donald Trump\",\n\t\t\t\t\t\tCreatedTime: now.Add(-24 * time.Hour),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tTicker: \"PRES-2024-HARRIS\",\n\t\t\t\t\t\tLookupValue: \"Kamala Harris\",\n\t\t\t\t\t\tCreatedTime: now.Add(-12 * time.Hour),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns lookup history with limit\",\n\t\t\tticker: \"SUPERBOWL\",\n\t\t\tparams: LookupHistoryParams{Limit: 5},\n\t\t\tserverResponse: models.LookupHistoryResponse{\n\t\t\t\tHistory: []models.LookupHistoryEntry{\n\t\t\t\t\t{Ticker: \"SB-CHIEFS\", LookupValue: \"Kansas City Chiefs\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\tticker: \"INVALID\",\n\t\t\tparams: LookupHistoryParams{},\n\t\t\tserverResponse: models.LookupHistoryResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/multivariate-collections/\" + tt.ticker + \"/lookup-history\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := multivariateTestClient(t, server.URL)\n\t\t\tresp, err := client.GetCollectionLookupHistory(context.Background(), tt.ticker, tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.History) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d history entries, got %d\", tt.wantCount, len(resp.History))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateCollectionMarket(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\trequest CreateCollectionMarketRequest\n\t\tserverResponse models.CreateCollectionMarketResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"creates market successfully\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\trequest: CreateCollectionMarketRequest{\n\t\t\t\tLookupValue: \"Donald Trump\",\n\t\t\t},\n\t\t\tserverResponse: models.CreateCollectionMarketResponse{\n\t\t\t\tMarketTicker: \"PRES-2024-TRUMP\",\n\t\t\t\tCreated: true,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"returns existing market\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\trequest: CreateCollectionMarketRequest{\n\t\t\t\tLookupValue: \"Kamala Harris\",\n\t\t\t},\n\t\t\tserverResponse: models.CreateCollectionMarketResponse{\n\t\t\t\tMarketTicker: \"PRES-2024-HARRIS\",\n\t\t\t\tCreated: false, // Market already existed\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles invalid lookup value\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\trequest: CreateCollectionMarketRequest{\n\t\t\t\tLookupValue: \"\",\n\t\t\t},\n\t\t\tserverResponse: models.CreateCollectionMarketResponse{},\n\t\t\tserverStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"handles collection not found\",\n\t\t\tticker: \"INVALID\",\n\t\t\trequest: CreateCollectionMarketRequest{LookupValue: \"Test\"},\n\t\t\tserverResponse: models.CreateCollectionMarketResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/multivariate-collections/\" + tt.ticker + \"/markets\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tvar req CreateCollectionMarketRequest\n\t\t\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\t\t\tt.Errorf(\"failed to decode request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif req.LookupValue != tt.request.LookupValue {\n\t\t\t\t\tt.Errorf(\"expected lookup_value %q, got %q\", tt.request.LookupValue, req.LookupValue)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := multivariateTestClient(t, server.URL)\n\t\t\tresp, err := client.CreateCollectionMarket(context.Background(), tt.ticker, tt.request)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.MarketTicker == \"\" {\n\t\t\t\tt.Error(\"expected market ticker to be returned\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLookupCollectionMarket(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tticker string\n\t\tlookupValue string\n\t\tserverResponse models.LookupCollectionMarketResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"finds market successfully\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\tlookupValue: \"Donald Trump\",\n\t\t\tserverResponse: models.LookupCollectionMarketResponse{\n\t\t\t\tMarketTicker: \"PRES-2024-TRUMP\",\n\t\t\t\tFound: true,\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"market not found (never created)\",\n\t\t\tticker: \"PRES-2024\",\n\t\t\tlookupValue: \"Unknown Candidate\",\n\t\t\tserverResponse: models.LookupCollectionMarketResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"collection not found\",\n\t\t\tticker: \"INVALID\",\n\t\t\tlookupValue: \"Test\",\n\t\t\tserverResponse: models.LookupCollectionMarketResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/multivariate-collections/\" + tt.ticker + \"/markets/lookup\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tif got := r.URL.Query().Get(\"lookup_value\"); got != tt.lookupValue {\n\t\t\t\t\tt.Errorf(\"expected lookup_value=%s, got %s\", tt.lookupValue, got)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := multivariateTestClient(t, server.URL)\n\t\t\tresp, err := client.LookupCollectionMarket(context.Background(), tt.ticker, tt.lookupValue)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif !resp.Found {\n\t\t\t\tt.Error(\"expected market to be found\")\n\t\t\t}\n\n\t\t\tif resp.MarketTicker == \"\" {\n\t\t\t\tt.Error(\"expected market ticker to be returned\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// multivariateTestClient creates a test client for multivariate collection tests\nfunc multivariateTestClient(t *testing.T, serverURL string) *Client {\n\tt.Helper()\n\tclient := NewClient(nil, nil)\n\tclient.SetBaseURL(serverURL)\n\treturn client\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":14514,"content_sha256":"864424528258791fb2976f85d94d0c1df3ca0c32071fd63adf20d053cbd2f1a7"},{"filename":"internal/api/multivariate.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 2: Implement to make tests pass (GREEN)\n// =============================================================================\n\n// ListMultivariateCollectionsParams contains parameters for listing multivariate collections\ntype ListMultivariateCollectionsParams struct {\n\tStatus string\n\tLimit int\n\tCursor string\n}\n\n// toQueryParams converts ListMultivariateCollectionsParams to query parameters\nfunc (p ListMultivariateCollectionsParams) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif p.Status != \"\" {\n\t\tparams[\"status\"] = p.Status\n\t}\n\tif p.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(p.Limit)\n\t}\n\tif p.Cursor != \"\" {\n\t\tparams[\"cursor\"] = p.Cursor\n\t}\n\treturn params\n}\n\n// LookupHistoryParams contains parameters for getting lookup history\ntype LookupHistoryParams struct {\n\tLimit int\n\tCursor string\n}\n\n// toQueryParams converts LookupHistoryParams to query parameters\nfunc (p LookupHistoryParams) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif p.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(p.Limit)\n\t}\n\tif p.Cursor != \"\" {\n\t\tparams[\"cursor\"] = p.Cursor\n\t}\n\treturn params\n}\n\n// CreateCollectionMarketRequest is the request to create a market in a collection\ntype CreateCollectionMarketRequest struct {\n\tLookupValue string `json:\"lookup_value\"`\n}\n\n// ListMultivariateCollections retrieves all multivariate collections\nfunc (c *Client) ListMultivariateCollections(ctx context.Context, params ListMultivariateCollectionsParams) (*models.MultivariateCollectionsResponse, error) {\n\tpath := TradeAPIPrefix + \"/multivariate-collections\" + BuildQueryString(params.toQueryParams())\n\n\tvar result models.MultivariateCollectionsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list multivariate collections: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetMultivariateCollection retrieves a single multivariate collection by ticker\nfunc (c *Client) GetMultivariateCollection(ctx context.Context, ticker string) (*models.MultivariateCollection, error) {\n\tif ticker == \"\" {\n\t\treturn nil, fmt.Errorf(\"ticker is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/multivariate-collections/\" + ticker\n\n\tvar result models.MultivariateCollectionResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get multivariate collection: %w\", err)\n\t}\n\n\treturn &result.Collection, nil\n}\n\n// GetCollectionLookupHistory retrieves the lookup history for a multivariate collection\nfunc (c *Client) GetCollectionLookupHistory(ctx context.Context, ticker string, params LookupHistoryParams) (*models.LookupHistoryResponse, error) {\n\tif ticker == \"\" {\n\t\treturn nil, fmt.Errorf(\"ticker is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/multivariate-collections/\" + ticker + \"/lookup-history\" + BuildQueryString(params.toQueryParams())\n\n\tvar result models.LookupHistoryResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get lookup history: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// CreateCollectionMarket creates a market in a multivariate collection\n// This must be called before trading on a specific lookup value\nfunc (c *Client) CreateCollectionMarket(ctx context.Context, ticker string, req CreateCollectionMarketRequest) (*models.CreateCollectionMarketResponse, error) {\n\tif ticker == \"\" {\n\t\treturn nil, fmt.Errorf(\"ticker is required\")\n\t}\n\tif req.LookupValue == \"\" {\n\t\treturn nil, fmt.Errorf(\"lookup_value is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/multivariate-collections/\" + ticker + \"/markets\"\n\n\tvar result models.CreateCollectionMarketResponse\n\tif err := c.DoRequest(ctx, \"POST\", path, req, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create collection market: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// LookupCollectionMarket looks up a market ticker by lookup value\n// Returns 404 if the market was never created\nfunc (c *Client) LookupCollectionMarket(ctx context.Context, ticker string, lookupValue string) (*models.LookupCollectionMarketResponse, error) {\n\tif ticker == \"\" {\n\t\treturn nil, fmt.Errorf(\"ticker is required\")\n\t}\n\tif lookupValue == \"\" {\n\t\treturn nil, fmt.Errorf(\"lookup_value is required\")\n\t}\n\n\tparams := map[string]string{\n\t\t\"lookup_value\": lookupValue,\n\t}\n\tpath := TradeAPIPrefix + \"/multivariate-collections/\" + ticker + \"/markets/lookup\" + BuildQueryString(params)\n\n\tvar result models.LookupCollectionMarketResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to lookup collection market: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4813,"content_sha256":"62de345ffa778b2075d4f0ed8c156ce13134d36143a194ffe1ed3038d87262eb"},{"filename":"internal/api/ordergroups_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestGetOrderGroups(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedGroups := []models.OrderGroup{\n\t\t{\n\t\t\tGroupID: \"group-1\",\n\t\t\tStatus: \"active\",\n\t\t\tLimit: 100,\n\t\t\tFilledCount: 50,\n\t\t\tOrderCount: 3,\n\t\t\tOrderIDs: []string{\"order-1\", \"order-2\", \"order-3\"},\n\t\t\tCreatedTime: now,\n\t\t\tLastUpdateTime: now,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderGroupsResponse{\n\t\t\tOrderGroups: expectedGroups,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetOrderGroups(context.Background(), OrderGroupsOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrderGroups failed: %v\", err)\n\t}\n\n\tif len(result.OrderGroups) != 1 {\n\t\tt.Errorf(\"expected 1 order group, got %d\", len(result.OrderGroups))\n\t}\n\tif result.OrderGroups[0].GroupID != \"group-1\" {\n\t\tt.Errorf(\"expected group ID 'group-1', got '%s'\", result.OrderGroups[0].GroupID)\n\t}\n}\n\nfunc TestGetOrderGroupsWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"status\") != \"active\" {\n\t\t\tt.Errorf(\"expected status 'active', got '%s'\", query.Get(\"status\"))\n\t\t}\n\t\tif query.Get(\"limit\") != \"10\" {\n\t\t\tt.Errorf(\"expected limit '10', got '%s'\", query.Get(\"limit\"))\n\t\t}\n\n\t\tresp := models.OrderGroupsResponse{OrderGroups: []models.OrderGroup{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetOrderGroups(context.Background(), OrderGroupsOptions{\n\t\tStatus: \"active\",\n\t\tLimit: 10,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrderGroups failed: %v\", err)\n\t}\n}\n\nfunc TestGetOrderGroup(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedGroup := models.OrderGroup{\n\t\tGroupID: \"group-123\",\n\t\tStatus: \"active\",\n\t\tLimit: 50,\n\t\tFilledCount: 25,\n\t\tOrderCount: 2,\n\t\tCreatedTime: now,\n\t\tLastUpdateTime: now,\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups/group-123\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderGroupResponse{OrderGroup: expectedGroup}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetOrderGroup(context.Background(), \"group-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrderGroup failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.GroupID != \"group-123\" {\n\t\tt.Errorf(\"expected group ID 'group-123', got '%s'\", result.OrderGroup.GroupID)\n\t}\n}\n\nfunc TestCreateOrderGroup(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.CreateOrderGroupRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.Limit != 100 {\n\t\t\tt.Errorf(\"expected limit 100, got %d\", req.Limit)\n\t\t}\n\n\t\tresp := models.CreateOrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"new-group-id\",\n\t\t\t\tLimit: req.Limit,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateOrderGroup(context.Background(), models.CreateOrderGroupRequest{\n\t\tLimit: 100,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"CreateOrderGroup failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.GroupID != \"new-group-id\" {\n\t\tt.Errorf(\"expected group ID 'new-group-id', got '%s'\", result.OrderGroup.GroupID)\n\t}\n\tif result.OrderGroup.Limit != 100 {\n\t\tt.Errorf(\"expected limit 100, got %d\", result.OrderGroup.Limit)\n\t}\n}\n\nfunc TestUpdateOrderGroupLimit(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH, got %s\", r.Method)\n\t\t}\n\t\t// Per Kalshi API spec: PATCH /order_groups/{group_id}/limit\n\t\texpectedPath := \"/trade-api/v2/portfolio/order_groups/group-123/limit\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path '%s', got '%s'\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tvar req models.UpdateOrderGroupLimitRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.Limit != 200 {\n\t\t\tt.Errorf(\"expected limit 200, got %d\", req.Limit)\n\t\t}\n\n\t\tresp := models.OrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"group-123\",\n\t\t\t\tLimit: req.Limit,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.UpdateOrderGroupLimit(context.Background(), \"group-123\", 200)\n\tif err != nil {\n\t\tt.Fatalf(\"UpdateOrderGroupLimit failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.Limit != 200 {\n\t\tt.Errorf(\"expected limit 200, got %d\", result.OrderGroup.Limit)\n\t}\n}\n\nfunc TestDeleteOrderGroup(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups/group-to-delete\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\terr := client.DeleteOrderGroup(context.Background(), \"group-to-delete\")\n\tif err != nil {\n\t\tt.Fatalf(\"DeleteOrderGroup failed: %v\", err)\n\t}\n}\n\nfunc TestOrderGroupsAPIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"error\": \"Order group not found\",\n\t\t\t\"code\": \"ORDER_GROUP_NOT_FOUND\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetOrderGroup(context.Background(), \"nonexistent\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for nonexistent order group\")\n\t}\n\n\tapiErr, ok := err.(*APIError)\n\tif !ok {\n\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t}\n\tif apiErr.StatusCode != 404 {\n\t\tt.Errorf(\"expected status 404, got %d\", apiErr.StatusCode)\n\t}\n}\n\n// TDD: Test for ResetOrderGroup - verifies POST to /order_groups/{id}/reset\nfunc TestResetOrderGroup(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups/group-456/reset\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"group-456\",\n\t\t\t\tStatus: \"active\",\n\t\t\t\tLimit: 100,\n\t\t\t\tFilledCount: 0, // Reset to 0\n\t\t\t\tOrderCount: 5,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.ResetOrderGroup(context.Background(), \"group-456\")\n\tif err != nil {\n\t\tt.Fatalf(\"ResetOrderGroup failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.GroupID != \"group-456\" {\n\t\tt.Errorf(\"expected group ID 'group-456', got '%s'\", result.OrderGroup.GroupID)\n\t}\n\tif result.OrderGroup.FilledCount != 0 {\n\t\tt.Errorf(\"expected filled count 0 after reset, got %d\", result.OrderGroup.FilledCount)\n\t}\n}\n\n// TDD: Test for TriggerOrderGroup - verifies POST to /order_groups/{id}/trigger\nfunc TestTriggerOrderGroup(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/order_groups/group-789/trigger\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"group-789\",\n\t\t\t\tStatus: \"triggered\",\n\t\t\t\tLimit: 50,\n\t\t\t\tFilledCount: 50,\n\t\t\t\tOrderCount: 0,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.TriggerOrderGroup(context.Background(), \"group-789\")\n\tif err != nil {\n\t\tt.Fatalf(\"TriggerOrderGroup failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.GroupID != \"group-789\" {\n\t\tt.Errorf(\"expected group ID 'group-789', got '%s'\", result.OrderGroup.GroupID)\n\t}\n\tif result.OrderGroup.Status != \"triggered\" {\n\t\tt.Errorf(\"expected status 'triggered', got '%s'\", result.OrderGroup.Status)\n\t}\n}\n\n// TDD: Test for UpdateOrderGroupLimit - verifies PATCH to /order_groups/{id}/limit\n// This test verifies the CORRECT endpoint path per Kalshi API spec\nfunc TestUpdateOrderGroupLimit_CorrectEndpoint(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH, got %s\", r.Method)\n\t\t}\n\t\t// Per Kalshi API spec, the endpoint should be /order_groups/{id}/limit\n\t\texpectedPath := \"/trade-api/v2/portfolio/order_groups/group-123/limit\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path '%s', got '%s'\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tvar req models.UpdateOrderGroupLimitRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.Limit != 200 {\n\t\t\tt.Errorf(\"expected limit 200, got %d\", req.Limit)\n\t\t}\n\n\t\tresp := models.OrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"group-123\",\n\t\t\t\tLimit: req.Limit,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.UpdateOrderGroupLimit(context.Background(), \"group-123\", 200)\n\tif err != nil {\n\t\tt.Fatalf(\"UpdateOrderGroupLimit failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.Limit != 200 {\n\t\tt.Errorf(\"expected limit 200, got %d\", result.OrderGroup.Limit)\n\t}\n}\n\n// TDD: Test for empty group ID validation\nfunc TestGetOrderGroup_EmptyGroupID(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\t_, err := client.GetOrderGroup(context.Background(), \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty group ID\")\n\t}\n\tif err.Error() != \"order group ID is required\" {\n\t\tt.Errorf(\"unexpected error message: %s\", err.Error())\n\t}\n}\n\nfunc TestUpdateOrderGroupLimit_EmptyGroupID(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\t_, err := client.UpdateOrderGroupLimit(context.Background(), \"\", 100)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty group ID\")\n\t}\n\tif err.Error() != \"order group ID is required\" {\n\t\tt.Errorf(\"unexpected error message: %s\", err.Error())\n\t}\n}\n\nfunc TestDeleteOrderGroup_EmptyGroupID(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\terr := client.DeleteOrderGroup(context.Background(), \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty group ID\")\n\t}\n\tif err.Error() != \"order group ID is required\" {\n\t\tt.Errorf(\"unexpected error message: %s\", err.Error())\n\t}\n}\n\nfunc TestResetOrderGroup_EmptyGroupID(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\t_, err := client.ResetOrderGroup(context.Background(), \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty group ID\")\n\t}\n\tif err.Error() != \"order group ID is required\" {\n\t\tt.Errorf(\"unexpected error message: %s\", err.Error())\n\t}\n}\n\nfunc TestTriggerOrderGroup_EmptyGroupID(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\t_, err := client.TriggerOrderGroup(context.Background(), \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty group ID\")\n\t}\n\tif err.Error() != \"order group ID is required\" {\n\t\tt.Errorf(\"unexpected error message: %s\", err.Error())\n\t}\n}\n\n// TDD: Test for CreateOrderGroup validation\nfunc TestCreateOrderGroup_ZeroLimit(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req models.CreateOrderGroupRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\t// API should accept zero limit (server-side validation)\n\t\tresp := models.CreateOrderGroupResponse{\n\t\t\tOrderGroup: models.OrderGroup{\n\t\t\t\tGroupID: \"new-group\",\n\t\t\t\tLimit: req.Limit,\n\t\t\t\tStatus: \"active\",\n\t\t\t\tCreatedTime: now,\n\t\t\t\tLastUpdateTime: now,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateOrderGroup(context.Background(), models.CreateOrderGroupRequest{\n\t\tLimit: 0,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"CreateOrderGroup failed: %v\", err)\n\t}\n\n\tif result.OrderGroup.Limit != 0 {\n\t\tt.Errorf(\"expected limit 0, got %d\", result.OrderGroup.Limit)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":14674,"content_sha256":"313cdb97b2bb98a7ff01ed3bd44a61c5cd5fe237e2daabaf1fc69264895ad294"},{"filename":"internal/api/ordergroups.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nconst orderGroupsBasePath = TradeAPIPrefix + \"/portfolio/order_groups\"\n\n// OrderGroupsOptions contains options for listing order groups\ntype OrderGroupsOptions struct {\n\tStatus string\n\tCursor string\n\tLimit int\n}\n\n// toQueryParams converts OrderGroupsOptions to query parameters\nfunc (o OrderGroupsOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Status != \"\" {\n\t\tparams[\"status\"] = o.Status\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\treturn params\n}\n\n// GetOrderGroups returns a list of order groups based on the provided options\nfunc (c *Client) GetOrderGroups(ctx context.Context, opts OrderGroupsOptions) (*models.OrderGroupsResponse, error) {\n\tpath := orderGroupsBasePath + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.OrderGroupsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetOrderGroup returns a single order group by ID\nfunc (c *Client) GetOrderGroup(ctx context.Context, groupID string) (*models.OrderGroupResponse, error) {\n\tif groupID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order group ID is required\")\n\t}\n\n\tpath := orderGroupsBasePath + \"/\" + groupID\n\n\tvar result models.OrderGroupResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateOrderGroup creates a new order group with the specified limit\nfunc (c *Client) CreateOrderGroup(ctx context.Context, req models.CreateOrderGroupRequest) (*models.CreateOrderGroupResponse, error) {\n\tvar result models.CreateOrderGroupResponse\n\tif err := c.PostJSON(ctx, orderGroupsBasePath, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// UpdateOrderGroupLimit updates the fill limit for an order group\n// Per Kalshi API spec: PATCH /order-groups/{group_id}/limit\nfunc (c *Client) UpdateOrderGroupLimit(ctx context.Context, groupID string, newLimit int) (*models.OrderGroupResponse, error) {\n\tif groupID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order group ID is required\")\n\t}\n\n\tpath := orderGroupsBasePath + \"/\" + groupID + \"/limit\"\n\treq := models.UpdateOrderGroupLimitRequest{Limit: newLimit}\n\n\tvar result models.OrderGroupResponse\n\tif err := c.DoRequest(ctx, \"PATCH\", path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// DeleteOrderGroup deletes an order group\nfunc (c *Client) DeleteOrderGroup(ctx context.Context, groupID string) error {\n\tif groupID == \"\" {\n\t\treturn fmt.Errorf(\"order group ID is required\")\n\t}\n\n\tpath := orderGroupsBasePath + \"/\" + groupID\n\treturn c.DeleteJSON(ctx, path, nil)\n}\n\n// ResetOrderGroup resets an order group's filled count\nfunc (c *Client) ResetOrderGroup(ctx context.Context, groupID string) (*models.OrderGroupResponse, error) {\n\tif groupID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order group ID is required\")\n\t}\n\n\tpath := orderGroupsBasePath + \"/\" + groupID + \"/reset\"\n\n\tvar result models.OrderGroupResponse\n\tif err := c.PostJSON(ctx, path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// TriggerOrderGroup triggers an order group to execute\nfunc (c *Client) TriggerOrderGroup(ctx context.Context, groupID string) (*models.OrderGroupResponse, error) {\n\tif groupID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order group ID is required\")\n\t}\n\n\tpath := orderGroupsBasePath + \"/\" + groupID + \"/trigger\"\n\n\tvar result models.OrderGroupResponse\n\tif err := c.PostJSON(ctx, path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3648,"content_sha256":"58e583f96e9e9ffa1510227afbb85480a7ba97f9e765868491554cefd2a8052d"},{"filename":"internal/api/orders_api_compliance_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// TestAmendOrderUsesCorrectHTTPMethod verifies that AmendOrder uses PATCH method\n// per Kalshi API spec: PATCH /orders/{order_id}\nfunc TestAmendOrderUsesCorrectHTTPMethod(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// API spec requires PATCH, not POST or PUT\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH method, got %s\", r.Method)\n\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\treturn\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-to-amend\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"order-to-amend\",\n\t\t\t\tYesPrice: 55,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.AmendOrder(context.Background(), \"order-to-amend\", models.AmendOrderRequest{\n\t\tPrice: 55,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AmendOrder failed: %v\", err)\n\t}\n}\n\n// TestDecreaseOrderUsesCorrectHTTPMethod verifies that DecreaseOrder uses PATCH method\n// per Kalshi API spec: PATCH /orders/{order_id}/decrease\nfunc TestDecreaseOrderUsesCorrectHTTPMethod(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// API spec requires PATCH, not POST\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH method, got %s\", r.Method)\n\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\treturn\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-to-decrease/decrease\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.DecreaseOrderRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.ReduceBy != 5 {\n\t\t\tt.Errorf(\"expected reduce_by 5, got %d\", req.ReduceBy)\n\t\t}\n\n\t\tresp := models.OrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"order-to-decrease\",\n\t\t\t\tRemainingCount: 5,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.DecreaseOrder(context.Background(), \"order-to-decrease\", 5)\n\tif err != nil {\n\t\tt.Fatalf(\"DecreaseOrder failed: %v\", err)\n\t}\n\n\tif result.Order.RemainingCount != 5 {\n\t\tt.Errorf(\"expected remaining quantity 5, got %d\", result.Order.RemainingCount)\n\t}\n}\n\n// TestBatchCreateOrdersUsesCorrectPath verifies the correct endpoint path\n// per Kalshi API spec: POST /orders/batch (not /orders/batched)\nfunc TestBatchCreateOrdersUsesCorrectPath(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\t// API spec uses /batch, not /batched\n\t\texpectedPath := \"/trade-api/v2/portfolio/orders/batch\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tvar req models.BatchCreateOrdersRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tresp := models.BatchCreateOrdersResponse{\n\t\t\tOrders: []models.Order{\n\t\t\t\t{OrderID: \"batch-order-1\"},\n\t\t\t\t{OrderID: \"batch-order-2\"},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.BatchCreateOrders(context.Background(), []models.CreateOrderRequest{\n\t\t{Ticker: \"BTC-100K\", Side: models.OrderSideYes, Count: 10},\n\t\t{Ticker: \"ETH-10K\", Side: models.OrderSideNo, Count: 20},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"BatchCreateOrders failed: %v\", err)\n\t}\n\n\tif len(result.Orders) != 2 {\n\t\tt.Errorf(\"expected 2 orders, got %d\", len(result.Orders))\n\t}\n}\n\n// TestBatchCancelOrdersUsesCorrectPath verifies the correct endpoint path\n// per Kalshi API spec: DELETE /orders/batch (not /orders)\nfunc TestBatchCancelOrdersUsesCorrectPath(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\t// API spec uses /batch for batch operations\n\t\texpectedPath := \"/trade-api/v2/portfolio/orders/batch\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tresp := models.BatchCancelOrdersResponse{\n\t\t\tOrders: []models.Order{\n\t\t\t\t{OrderID: \"cancel-1\", Status: models.OrderStatusCanceled},\n\t\t\t\t{OrderID: \"cancel-2\", Status: models.OrderStatusCanceled},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.BatchCancelOrders(context.Background(), models.BatchCancelOrdersRequest{\n\t\tOrderIDs: []string{\"cancel-1\", \"cancel-2\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"BatchCancelOrders failed: %v\", err)\n\t}\n\n\tif len(result.Orders) != 2 {\n\t\tt.Errorf(\"expected 2 orders, got %d\", len(result.Orders))\n\t}\n}\n\n// TestGetQueuePositionUsesCorrectPath verifies the correct endpoint path\n// per Kalshi API spec: GET /orders/{order_id}/queue-position (not /position)\nfunc TestGetQueuePositionUsesCorrectPath(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\t// API spec uses /queue-position, not /position\n\t\texpectedPath := \"/trade-api/v2/portfolio/orders/order-123/queue-position\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tresp := models.QueuePosition{\n\t\t\tOrderID: \"order-123\",\n\t\t\tQueuePosition: 5,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetQueuePosition(context.Background(), \"order-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetQueuePosition failed: %v\", err)\n\t}\n\n\tif result.QueuePosition != 5 {\n\t\tt.Errorf(\"expected queue position 5, got %d\", result.QueuePosition)\n\t}\n}\n\n// TestGetAllQueuePositionsUsesCorrectPath verifies the correct endpoint path\n// per Kalshi API spec: GET /orders/queue-positions (not /positions)\nfunc TestGetAllQueuePositionsUsesCorrectPath(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\t// API spec uses /queue-positions, not /positions\n\t\texpectedPath := \"/trade-api/v2/portfolio/orders/queue-positions\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tresp := models.QueuePositionsResponse{\n\t\t\tPositions: []models.QueuePosition{\n\t\t\t\t{OrderID: \"order-1\", QueuePosition: 3},\n\t\t\t\t{OrderID: \"order-2\", QueuePosition: 7},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetAllQueuePositions(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllQueuePositions failed: %v\", err)\n\t}\n\n\tif len(result.Positions) != 2 {\n\t\tt.Errorf(\"expected 2 positions, got %d\", len(result.Positions))\n\t}\n}\n\n// TestBatchCreateOrdersLimitValidation verifies the 20 order limit for batch creation\n// per Kalshi API spec: max 20 orders per batch\nfunc TestBatchCreateOrdersLimitValidation(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\n\t// Create 21 orders (exceeds limit)\n\torders := make([]models.CreateOrderRequest, 21)\n\tfor i := range orders {\n\t\torders[i] = models.CreateOrderRequest{\n\t\t\tTicker: \"TEST-TICKER\",\n\t\t\tSide: models.OrderSideYes,\n\t\t\tCount: 1,\n\t\t}\n\t}\n\n\t_, err := client.BatchCreateOrders(context.Background(), orders)\n\tif err == nil {\n\t\tt.Error(\"expected error for exceeding batch limit of 20 orders\")\n\t}\n}\n\n// TestBatchCancelOrdersLimitValidation verifies the 20 order limit for batch cancellation\n// per Kalshi API spec: max 20 orders per batch\nfunc TestBatchCancelOrdersLimitValidation(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\n\t// Create 21 order IDs (exceeds limit)\n\torderIDs := make([]string, 21)\n\tfor i := range orderIDs {\n\t\torderIDs[i] = \"order-\" + string(rune('a'+i))\n\t}\n\n\t_, err := client.BatchCancelOrders(context.Background(), models.BatchCancelOrdersRequest{\n\t\tOrderIDs: orderIDs,\n\t})\n\tif err == nil {\n\t\tt.Error(\"expected error for exceeding batch limit of 20 orders\")\n\t}\n}\n\n// TestDecreaseOrderValidatesPositiveAmount ensures reduce_by must be positive\nfunc TestDecreaseOrderValidatesPositiveAmount(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\n\t_, err := client.DecreaseOrder(context.Background(), \"order-123\", 0)\n\tif err == nil {\n\t\tt.Error(\"expected error for zero reduce_by amount\")\n\t}\n\n\t_, err = client.DecreaseOrder(context.Background(), \"order-123\", -5)\n\tif err == nil {\n\t\tt.Error(\"expected error for negative reduce_by amount\")\n\t}\n}\n\n// TestAmendOrderValidatesAtLeastOneField ensures at least price or count is specified\nfunc TestAmendOrderValidatesAtLeastOneField(t *testing.T) {\n\tclient := createTestClient(t, \"http://localhost\")\n\n\t_, err := client.AmendOrder(context.Background(), \"order-123\", models.AmendOrderRequest{})\n\tif err == nil {\n\t\tt.Error(\"expected error when neither price nor count is specified\")\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":9728,"content_sha256":"5569003dfcbbea00486795c04b5dd846b0352a36b4bab4b8cf2a09d86cb365ac"},{"filename":"internal/api/orders_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestGetOrders(t *testing.T) {\n\texpectedOrders := []models.Order{\n\t\t{\n\t\t\tOrderID: \"order-1\",\n\t\t\tTicker: \"BTC-100K\",\n\t\t\tStatus: models.OrderStatusResting,\n\t\t\tSide: models.OrderSideYes,\n\t\t},\n\t\t{\n\t\t\tOrderID: \"order-2\",\n\t\t\tTicker: \"ETH-10K\",\n\t\t\tStatus: models.OrderStatusExecuted,\n\t\t\tSide: models.OrderSideNo,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrdersResponse{\n\t\t\tOrders: expectedOrders,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetOrders(context.Background(), OrdersOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrders failed: %v\", err)\n\t}\n\n\tif len(result.Orders) != 2 {\n\t\tt.Errorf(\"expected 2 orders, got %d\", len(result.Orders))\n\t}\n\tif result.Orders[0].OrderID != \"order-1\" {\n\t\tt.Errorf(\"expected order ID 'order-1', got '%s'\", result.Orders[0].OrderID)\n\t}\n\tif result.Cursor != \"next-cursor\" {\n\t\tt.Errorf(\"expected cursor 'next-cursor', got '%s'\", result.Cursor)\n\t}\n}\n\nfunc TestGetOrdersWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"ticker\") != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", query.Get(\"ticker\"))\n\t\t}\n\t\tif query.Get(\"status\") != \"resting\" {\n\t\t\tt.Errorf(\"expected status 'resting', got '%s'\", query.Get(\"status\"))\n\t\t}\n\t\tif query.Get(\"limit\") != \"50\" {\n\t\t\tt.Errorf(\"expected limit '50', got '%s'\", query.Get(\"limit\"))\n\t\t}\n\n\t\tresp := models.OrdersResponse{Orders: []models.Order{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetOrders(context.Background(), OrdersOptions{\n\t\tTicker: \"BTC-100K\",\n\t\tStatus: \"resting\",\n\t\tLimit: 50,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrders failed: %v\", err)\n\t}\n}\n\nfunc TestGetOrder(t *testing.T) {\n\texpectedOrder := models.Order{\n\t\tOrderID: \"order-123\",\n\t\tTicker: \"BTC-100K\",\n\t\tStatus: models.OrderStatusResting,\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-123\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderResponse{Order: expectedOrder}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetOrder(context.Background(), \"order-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetOrder failed: %v\", err)\n\t}\n\n\tif result.Order.OrderID != \"order-123\" {\n\t\tt.Errorf(\"expected order ID 'order-123', got '%s'\", result.Order.OrderID)\n\t}\n}\n\nfunc TestCreateOrder(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.CreateOrderRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.Ticker != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", req.Ticker)\n\t\t}\n\t\tif req.Side != models.OrderSideYes {\n\t\t\tt.Errorf(\"expected side 'yes', got '%s'\", req.Side)\n\t\t}\n\t\tif req.Count != 10 {\n\t\t\tt.Errorf(\"expected count 10, got %d\", req.Count)\n\t\t}\n\n\t\tresp := models.CreateOrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"new-order-id\",\n\t\t\t\tTicker: req.Ticker,\n\t\t\t\tSide: req.Side,\n\t\t\t\tStatus: models.OrderStatusResting,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateOrder(context.Background(), models.CreateOrderRequest{\n\t\tTicker: \"BTC-100K\",\n\t\tSide: models.OrderSideYes,\n\t\tAction: models.OrderActionBuy,\n\t\tType: models.OrderTypeLimit,\n\t\tCount: 10,\n\t\tYesPrice: 50,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"CreateOrder failed: %v\", err)\n\t}\n\n\tif result.Order.OrderID != \"new-order-id\" {\n\t\tt.Errorf(\"expected order ID 'new-order-id', got '%s'\", result.Order.OrderID)\n\t}\n}\n\nfunc TestCancelOrder(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-to-cancel\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.OrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"order-to-cancel\",\n\t\t\t\tStatus: models.OrderStatusCanceled,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CancelOrder(context.Background(), \"order-to-cancel\")\n\tif err != nil {\n\t\tt.Fatalf(\"CancelOrder failed: %v\", err)\n\t}\n\n\tif result.Order.Status != models.OrderStatusCanceled {\n\t\tt.Errorf(\"expected status 'canceled', got '%s'\", result.Order.Status)\n\t}\n}\n\nfunc TestAmendOrder(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-to-amend\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.AmendOrderRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.Price != 55 {\n\t\t\tt.Errorf(\"expected price 55, got %d\", req.Price)\n\t\t}\n\n\t\tresp := models.OrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"order-to-amend\",\n\t\t\t\tYesPrice: 55,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.AmendOrder(context.Background(), \"order-to-amend\", models.AmendOrderRequest{\n\t\tPrice: 55,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AmendOrder failed: %v\", err)\n\t}\n\n\tif result.Order.YesPrice != 55 {\n\t\tt.Errorf(\"expected price 55, got %d\", result.Order.YesPrice)\n\t}\n}\n\nfunc TestDecreaseOrder(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPatch {\n\t\t\tt.Errorf(\"expected PATCH, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-to-decrease/decrease\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.DecreaseOrderRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.ReduceBy != 5 {\n\t\t\tt.Errorf(\"expected reduce_by 5, got %d\", req.ReduceBy)\n\t\t}\n\n\t\tresp := models.OrderResponse{\n\t\t\tOrder: models.Order{\n\t\t\t\tOrderID: \"order-to-decrease\",\n\t\t\t\tRemainingCount: 5,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.DecreaseOrder(context.Background(), \"order-to-decrease\", 5)\n\tif err != nil {\n\t\tt.Fatalf(\"DecreaseOrder failed: %v\", err)\n\t}\n\n\tif result.Order.RemainingCount != 5 {\n\t\tt.Errorf(\"expected remaining quantity 5, got %d\", result.Order.RemainingCount)\n\t}\n}\n\nfunc TestBatchCreateOrders(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/batch\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.BatchCreateOrdersRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif len(req.Orders) != 2 {\n\t\t\tt.Errorf(\"expected 2 orders, got %d\", len(req.Orders))\n\t\t}\n\n\t\tresp := models.BatchCreateOrdersResponse{\n\t\t\tOrders: []models.Order{\n\t\t\t\t{OrderID: \"batch-order-1\"},\n\t\t\t\t{OrderID: \"batch-order-2\"},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.BatchCreateOrders(context.Background(), []models.CreateOrderRequest{\n\t\t{Ticker: \"BTC-100K\", Side: models.OrderSideYes, Count: 10},\n\t\t{Ticker: \"ETH-10K\", Side: models.OrderSideNo, Count: 20},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"BatchCreateOrders failed: %v\", err)\n\t}\n\n\tif len(result.Orders) != 2 {\n\t\tt.Errorf(\"expected 2 orders, got %d\", len(result.Orders))\n\t}\n}\n\nfunc TestBatchCancelOrders(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodDelete {\n\t\t\tt.Errorf(\"expected DELETE, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/batch\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.BatchCancelOrdersRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tresp := models.BatchCancelOrdersResponse{\n\t\t\tOrders: []models.Order{\n\t\t\t\t{OrderID: \"cancel-1\", Status: models.OrderStatusCanceled},\n\t\t\t\t{OrderID: \"cancel-2\", Status: models.OrderStatusCanceled},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.BatchCancelOrders(context.Background(), models.BatchCancelOrdersRequest{\n\t\tOrderIDs: []string{\"cancel-1\", \"cancel-2\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"BatchCancelOrders failed: %v\", err)\n\t}\n\n\tif len(result.Orders) != 2 {\n\t\tt.Errorf(\"expected 2 orders, got %d\", len(result.Orders))\n\t}\n}\n\nfunc TestGetQueuePosition(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/order-123/queue-position\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.QueuePosition{\n\t\t\tOrderID: \"order-123\",\n\t\t\tQueuePosition: 5,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetQueuePosition(context.Background(), \"order-123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetQueuePosition failed: %v\", err)\n\t}\n\n\tif result.QueuePosition != 5 {\n\t\tt.Errorf(\"expected queue position 5, got %d\", result.QueuePosition)\n\t}\n}\n\nfunc TestGetAllQueuePositions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/orders/queue-positions\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.QueuePositionsResponse{\n\t\t\tPositions: []models.QueuePosition{\n\t\t\t\t{OrderID: \"order-1\", QueuePosition: 3},\n\t\t\t\t{OrderID: \"order-2\", QueuePosition: 7},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetAllQueuePositions(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllQueuePositions failed: %v\", err)\n\t}\n\n\tif len(result.Positions) != 2 {\n\t\tt.Errorf(\"expected 2 positions, got %d\", len(result.Positions))\n\t}\n}\n\nfunc TestOrdersAPIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"error\": \"Order not found\",\n\t\t\t\"code\": \"ORDER_NOT_FOUND\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetOrder(context.Background(), \"nonexistent\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for nonexistent order\")\n\t}\n\n\tapiErr, ok := err.(*APIError)\n\tif !ok {\n\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t}\n\tif apiErr.StatusCode != 404 {\n\t\tt.Errorf(\"expected status 404, got %d\", apiErr.StatusCode)\n\t}\n}\n\nfunc createTestClient(t *testing.T, baseURL string) *Client {\n\tt.Helper()\n\n\tprivateKey, err := generateTestKey()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate test key: %v\", err)\n\t}\n\n\tsigner, err := NewSigner(\"test-api-key\", privateKey)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create signer: %v\", err)\n\t}\n\n\treturn NewClientLegacy(signer, WithBaseURL(baseURL))\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":13292,"content_sha256":"6ce7c227927602c200968229c3c0a09a384a0b29cb5866418ae4ff915d58be26"},{"filename":"internal/api/orders.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nconst ordersBasePath = TradeAPIPrefix + \"/portfolio/orders\"\n\n// OrdersOptions contains options for listing orders\ntype OrdersOptions struct {\n\tTicker string\n\tEventTicker string\n\tStatus string\n\tCursor string\n\tLimit int\n\tSubaccountID int\n}\n\n// toQueryParams converts OrdersOptions to query parameters\nfunc (o OrdersOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Ticker != \"\" {\n\t\tparams[\"ticker\"] = o.Ticker\n\t}\n\tif o.EventTicker != \"\" {\n\t\tparams[\"event_ticker\"] = o.EventTicker\n\t}\n\tif o.Status != \"\" {\n\t\tparams[\"status\"] = o.Status\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\tif o.SubaccountID > 0 {\n\t\tparams[\"subaccount_id\"] = strconv.Itoa(o.SubaccountID)\n\t}\n\treturn params\n}\n\n// GetOrders returns a list of orders based on the provided options\nfunc (c *Client) GetOrders(ctx context.Context, opts OrdersOptions) (*models.OrdersResponse, error) {\n\tpath := ordersBasePath + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.OrdersResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetOrder returns a single order by ID\nfunc (c *Client) GetOrder(ctx context.Context, orderID string) (*models.OrderResponse, error) {\n\tif orderID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order ID is required\")\n\t}\n\n\tpath := ordersBasePath + \"/\" + orderID\n\n\tvar result models.OrderResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateOrder creates a new order\nfunc (c *Client) CreateOrder(ctx context.Context, req models.CreateOrderRequest) (*models.CreateOrderResponse, error) {\n\tvar result models.CreateOrderResponse\n\tif err := c.PostJSON(ctx, ordersBasePath, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CancelOrder cancels an existing order\nfunc (c *Client) CancelOrder(ctx context.Context, orderID string) (*models.OrderResponse, error) {\n\tif orderID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order ID is required\")\n\t}\n\n\tpath := ordersBasePath + \"/\" + orderID\n\n\tvar result models.OrderResponse\n\tif err := c.DeleteJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// AmendOrder amends an existing order's price or count\n// API spec: PATCH /orders/{order_id}\nfunc (c *Client) AmendOrder(ctx context.Context, orderID string, req models.AmendOrderRequest) (*models.OrderResponse, error) {\n\tif orderID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order ID is required\")\n\t}\n\tif req.Price == 0 && req.Count == 0 {\n\t\treturn nil, fmt.Errorf(\"at least one of price or count must be specified\")\n\t}\n\n\tpath := ordersBasePath + \"/\" + orderID\n\n\tvar result models.OrderResponse\n\tif err := c.PatchJSON(ctx, path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// DecreaseOrder decreases an order's quantity\n// API spec: PATCH /orders/{order_id}/decrease\nfunc (c *Client) DecreaseOrder(ctx context.Context, orderID string, reduceBy int) (*models.OrderResponse, error) {\n\tif orderID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order ID is required\")\n\t}\n\tif reduceBy \u003c= 0 {\n\t\treturn nil, fmt.Errorf(\"reduce_by must be a positive integer, got %d\", reduceBy)\n\t}\n\n\tpath := ordersBasePath + \"/\" + orderID + \"/decrease\"\n\treq := models.DecreaseOrderRequest{ReduceBy: reduceBy}\n\n\tvar result models.OrderResponse\n\tif err := c.PatchJSON(ctx, path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// BatchCreateOrders creates multiple orders in a single request\n// API spec: POST /orders/batch (max 20 orders per batch)\nfunc (c *Client) BatchCreateOrders(ctx context.Context, orders []models.CreateOrderRequest) (*models.BatchCreateOrdersResponse, error) {\n\tif len(orders) > 20 {\n\t\treturn nil, fmt.Errorf(\"batch create supports max 20 orders, got %d\", len(orders))\n\t}\n\n\tpath := ordersBasePath + \"/batch\"\n\treq := models.BatchCreateOrdersRequest{Orders: orders}\n\n\tvar result models.BatchCreateOrdersResponse\n\tif err := c.PostJSON(ctx, path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// BatchCancelOrders cancels multiple orders in a single request\n// API spec: DELETE /orders/batch (max 20 orders per batch)\nfunc (c *Client) BatchCancelOrders(ctx context.Context, req models.BatchCancelOrdersRequest) (*models.BatchCancelOrdersResponse, error) {\n\tif len(req.OrderIDs) > 20 {\n\t\treturn nil, fmt.Errorf(\"batch cancel supports max 20 orders, got %d\", len(req.OrderIDs))\n\t}\n\n\tpath := ordersBasePath + \"/batch\"\n\n\tvar result models.BatchCancelOrdersResponse\n\tif err := c.DeleteWithBody(ctx, path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetQueuePosition returns the queue position for a specific order\n// API spec: GET /orders/{order_id}/queue-position\nfunc (c *Client) GetQueuePosition(ctx context.Context, orderID string) (*models.QueuePosition, error) {\n\tif orderID == \"\" {\n\t\treturn nil, fmt.Errorf(\"order ID is required\")\n\t}\n\n\tpath := ordersBasePath + \"/\" + orderID + \"/queue-position\"\n\n\tvar result models.QueuePosition\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetAllQueuePositions returns queue positions for all resting orders\n// API spec: GET /orders/queue-positions\nfunc (c *Client) GetAllQueuePositions(ctx context.Context) (*models.QueuePositionsResponse, error) {\n\tpath := ordersBasePath + \"/queue-positions\"\n\n\tvar result models.QueuePositionsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5694,"content_sha256":"d8e1efd0224984a0130a01c43daa0b5b19ee6fa3f625a9c57a864b6bbd04dc99"},{"filename":"internal/api/portfolio_audit_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// TestTransferPathFix verifies the transfer endpoints use the correct path\nfunc TestTransferPathFix(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedTransfers := []models.Transfer{\n\t\t{\n\t\t\tTransferID: \"transfer-1\",\n\t\t\tFromSubaccount: 1,\n\t\t\tToSubaccount: 2,\n\t\t\tAmount: 10000,\n\t\t\tCreatedTime: now,\n\t\t},\n\t}\n\n\tt.Run(\"GetTransfers uses correct subaccounts path\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method != http.MethodGet {\n\t\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t\t}\n\t\t\texpectedPath := \"/trade-api/v2/portfolio/subaccounts/transfers\"\n\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\tt.Errorf(\"incorrect path: got %s, want %s\", r.URL.Path, expectedPath)\n\t\t\t}\n\n\t\t\tresp := models.TransfersResponse{Transfers: expectedTransfers}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(resp)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\tresult, err := client.GetTransfers(context.Background())\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetTransfers failed: %v\", err)\n\t\t}\n\n\t\tif len(result.Transfers) != 1 {\n\t\t\tt.Errorf(\"expected 1 transfer, got %d\", len(result.Transfers))\n\t\t}\n\t})\n\n\tt.Run(\"Transfer (POST) uses correct subaccounts path\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method != http.MethodPost {\n\t\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t\t}\n\t\t\texpectedPath := \"/trade-api/v2/portfolio/subaccounts/transfers\"\n\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\tt.Errorf(\"incorrect path: got %s, want %s\", r.URL.Path, expectedPath)\n\t\t\t}\n\n\t\t\tvar req models.TransferRequest\n\t\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t\t}\n\n\t\t\tresp := models.Transfer{\n\t\t\t\tTransferID: \"new-transfer\",\n\t\t\t\tFromSubaccount: req.FromSubaccount,\n\t\t\t\tToSubaccount: req.ToSubaccount,\n\t\t\t\tAmount: req.Amount,\n\t\t\t\tCreatedTime: now,\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(resp)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\tresult, err := client.Transfer(context.Background(), models.TransferRequest{\n\t\t\tFromSubaccount: 1,\n\t\t\tToSubaccount: 2,\n\t\t\tAmount: 5000,\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Transfer failed: %v\", err)\n\t\t}\n\n\t\tif result.TransferID != \"new-transfer\" {\n\t\t\tt.Errorf(\"expected transfer ID 'new-transfer', got '%s'\", result.TransferID)\n\t\t}\n\t})\n}\n\n// TestGetSubaccountBalances verifies the new endpoint\nfunc TestGetSubaccountBalancesEndpoint(t *testing.T) {\n\texpectedBalances := []models.SubaccountBalance{\n\t\t{SubaccountID: 1, Balance: 50000, AvailableBalance: 45000},\n\t\t{SubaccountID: 2, Balance: 25000, AvailableBalance: 25000},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\texpectedPath := \"/trade-api/v2/portfolio/subaccounts/balances\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"incorrect path: got %s, want %s\", r.URL.Path, expectedPath)\n\t\t}\n\n\t\tresp := models.SubaccountBalancesResponse{Balances: expectedBalances}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetSubaccountBalances(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetSubaccountBalances failed: %v\", err)\n\t}\n\n\tif len(result.Balances) != 2 {\n\t\tt.Errorf(\"expected 2 balances, got %d\", len(result.Balances))\n\t}\n\tif result.Balances[0].SubaccountID != 1 {\n\t\tt.Errorf(\"expected subaccount ID 1, got %d\", result.Balances[0].SubaccountID)\n\t}\n\tif result.Balances[0].Balance != 50000 {\n\t\tt.Errorf(\"expected balance 50000, got %d\", result.Balances[0].Balance)\n\t}\n}\n\n// TestGetRestingOrderValue verifies the new FCM endpoint\nfunc TestGetRestingOrderValueEndpoint(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\texpectedPath := \"/trade-api/v2/portfolio/resting-order-value\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"incorrect path: got %s, want %s\", r.URL.Path, expectedPath)\n\t\t}\n\n\t\tresp := models.RestingOrderValueResponse{RestingOrderValue: 150000}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetRestingOrderValue(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetRestingOrderValue failed: %v\", err)\n\t}\n\n\tif result.RestingOrderValue != 150000 {\n\t\tt.Errorf(\"expected resting order value 150000, got %d\", result.RestingOrderValue)\n\t}\n}\n\n// TestBalanceResponseFullFields verifies all balance fields are parsed\nfunc TestBalanceResponseFullFields(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\texpectedPath := \"/trade-api/v2/portfolio/balance\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"incorrect path: got %s, want %s\", r.URL.Path, expectedPath)\n\t\t}\n\n\t\tresp := models.BalanceResponse{\n\t\t\tBalance: 100000,\n\t\t\tPortfolioValue: 50000,\n\t\t\tUpdatedTs: 1700000000,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetBalance(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetBalance failed: %v\", err)\n\t}\n\n\tif result.Balance != 100000 {\n\t\tt.Errorf(\"expected balance 100000, got %d\", result.Balance)\n\t}\n\tif result.PortfolioValue != 50000 {\n\t\tt.Errorf(\"expected portfolio_value 50000, got %d\", result.PortfolioValue)\n\t}\n\tif result.UpdatedTs != 1700000000 {\n\t\tt.Errorf(\"expected updated_ts 1700000000, got %d\", result.UpdatedTs)\n\t}\n}\n\n// TestEdgeCases tests edge cases and error handling\nfunc TestPortfolioEdgeCases(t *testing.T) {\n\tt.Run(\"empty transfers list\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tresp := models.TransfersResponse{Transfers: []models.Transfer{}}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(resp)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\tresult, err := client.GetTransfers(context.Background())\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetTransfers failed: %v\", err)\n\t\t}\n\n\t\tif result.Transfers == nil {\n\t\t\tt.Error(\"expected empty slice, got nil\")\n\t\t}\n\t\tif len(result.Transfers) != 0 {\n\t\t\tt.Errorf(\"expected 0 transfers, got %d\", len(result.Transfers))\n\t\t}\n\t})\n\n\tt.Run(\"empty subaccount balances\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tresp := models.SubaccountBalancesResponse{Balances: []models.SubaccountBalance{}}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(resp)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\tresult, err := client.GetSubaccountBalances(context.Background())\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetSubaccountBalances failed: %v\", err)\n\t\t}\n\n\t\tif result.Balances == nil {\n\t\t\tt.Error(\"expected empty slice, got nil\")\n\t\t}\n\t\tif len(result.Balances) != 0 {\n\t\t\tt.Errorf(\"expected 0 balances, got %d\", len(result.Balances))\n\t\t}\n\t})\n\n\tt.Run(\"zero resting order value\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tresp := models.RestingOrderValueResponse{RestingOrderValue: 0}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(resp)\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\tresult, err := client.GetRestingOrderValue(context.Background())\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetRestingOrderValue failed: %v\", err)\n\t\t}\n\n\t\tif result.RestingOrderValue != 0 {\n\t\t\tt.Errorf(\"expected 0, got %d\", result.RestingOrderValue)\n\t\t}\n\t})\n\n\tt.Run(\"API error on transfer\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\t\"error\": \"insufficient_balance\",\n\t\t\t\t\"code\": \"INSUFFICIENT_BALANCE\",\n\t\t\t})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\t_, err := client.Transfer(context.Background(), models.TransferRequest{\n\t\t\tFromSubaccount: 1,\n\t\t\tToSubaccount: 2,\n\t\t\tAmount: 9999999999,\n\t\t})\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for insufficient balance\")\n\t\t}\n\n\t\tapiErr, ok := err.(*APIError)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t\t}\n\t\tif apiErr.StatusCode != 400 {\n\t\t\tt.Errorf(\"expected status 400, got %d\", apiErr.StatusCode)\n\t\t}\n\t})\n\n\tt.Run(\"API error on resting order value (non-FCM)\", func(t *testing.T) {\n\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\t\"error\": \"fcm_only\",\n\t\t\t\t\"code\": \"FCM_ONLY\",\n\t\t\t})\n\t\t}))\n\t\tdefer server.Close()\n\n\t\tclient := createTestClient(t, server.URL)\n\t\t_, err := client.GetRestingOrderValue(context.Background())\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for non-FCM account\")\n\t\t}\n\n\t\tapiErr, ok := err.(*APIError)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t\t}\n\t\tif apiErr.StatusCode != 403 {\n\t\t\tt.Errorf(\"expected status 403, got %d\", apiErr.StatusCode)\n\t\t}\n\t})\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":9907,"content_sha256":"a61d35a9b82adcab673f66bc53ffb380c02f552884da0d15d3d0f459f436ea01"},{"filename":"internal/api/portfolio_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc TestGetBalance(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/balance\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.BalanceResponse{Balance: 100000}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetBalance(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetBalance failed: %v\", err)\n\t}\n\n\tif result.Balance != 100000 {\n\t\tt.Errorf(\"expected balance 100000, got %d\", result.Balance)\n\t}\n}\n\nfunc TestGetPositions(t *testing.T) {\n\texpectedPositions := []models.Position{\n\t\t{\n\t\t\tTicker: \"BTC-100K\",\n\t\t\tPosition: 10,\n\t\t},\n\t\t{\n\t\t\tTicker: \"ETH-10K\",\n\t\t\tPosition: -5,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/positions\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.PositionsResponse{\n\t\t\tPositions: expectedPositions,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetPositions(context.Background(), PositionsOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetPositions failed: %v\", err)\n\t}\n\n\tif len(result.Positions) != 2 {\n\t\tt.Errorf(\"expected 2 positions, got %d\", len(result.Positions))\n\t}\n\tif result.Positions[0].Ticker != \"BTC-100K\" {\n\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", result.Positions[0].Ticker)\n\t}\n}\n\nfunc TestGetPositionsWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"ticker\") != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", query.Get(\"ticker\"))\n\t\t}\n\t\tif query.Get(\"event_ticker\") != \"BTC-2024\" {\n\t\t\tt.Errorf(\"expected event_ticker 'BTC-2024', got '%s'\", query.Get(\"event_ticker\"))\n\t\t}\n\t\tif query.Get(\"limit\") != \"25\" {\n\t\t\tt.Errorf(\"expected limit '25', got '%s'\", query.Get(\"limit\"))\n\t\t}\n\n\t\tresp := models.PositionsResponse{Positions: []models.Position{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetPositions(context.Background(), PositionsOptions{\n\t\tTicker: \"BTC-100K\",\n\t\tEventTicker: \"BTC-2024\",\n\t\tLimit: 25,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetPositions failed: %v\", err)\n\t}\n}\n\nfunc TestGetFills(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedFills := []models.Fill{\n\t\t{\n\t\t\tTradeID: \"trade-1\",\n\t\t\tOrderID: \"order-1\",\n\t\t\tTicker: \"BTC-100K\",\n\t\t\tSide: \"yes\",\n\t\t\tCount: 5,\n\t\t\tCreatedTime: now,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/fills\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.FillsResponse{\n\t\t\tFills: expectedFills,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetFills(context.Background(), FillsOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetFills failed: %v\", err)\n\t}\n\n\tif len(result.Fills) != 1 {\n\t\tt.Errorf(\"expected 1 fill, got %d\", len(result.Fills))\n\t}\n\tif result.Fills[0].TradeID != \"trade-1\" {\n\t\tt.Errorf(\"expected trade ID 'trade-1', got '%s'\", result.Fills[0].TradeID)\n\t}\n}\n\nfunc TestGetFillsWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"ticker\") != \"BTC-100K\" {\n\t\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", query.Get(\"ticker\"))\n\t\t}\n\t\tif query.Get(\"order_id\") != \"order-123\" {\n\t\t\tt.Errorf(\"expected order_id 'order-123', got '%s'\", query.Get(\"order_id\"))\n\t\t}\n\n\t\tresp := models.FillsResponse{Fills: []models.Fill{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetFills(context.Background(), FillsOptions{\n\t\tTicker: \"BTC-100K\",\n\t\tOrderID: \"order-123\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetFills failed: %v\", err)\n\t}\n}\n\nfunc TestGetSettlements(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedSettlements := []models.Settlement{\n\t\t{\n\t\t\tTicker: \"BTC-100K\",\n\t\t\tMarketResult: \"yes\",\n\t\t\tRevenue: 500,\n\t\t\tSettledTime: now,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/settlements\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.SettlementsResponse{\n\t\t\tSettlements: expectedSettlements,\n\t\t\tCursor: \"next-cursor\",\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetSettlements(context.Background(), SettlementsOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"GetSettlements failed: %v\", err)\n\t}\n\n\tif len(result.Settlements) != 1 {\n\t\tt.Errorf(\"expected 1 settlement, got %d\", len(result.Settlements))\n\t}\n\tif result.Settlements[0].Ticker != \"BTC-100K\" {\n\t\tt.Errorf(\"expected ticker 'BTC-100K', got '%s'\", result.Settlements[0].Ticker)\n\t}\n}\n\nfunc TestGetSettlementsWithOptions(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := r.URL.Query()\n\n\t\tif query.Get(\"limit\") != \"100\" {\n\t\t\tt.Errorf(\"expected limit '100', got '%s'\", query.Get(\"limit\"))\n\t\t}\n\n\t\tresp := models.SettlementsResponse{Settlements: []models.Settlement{}}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetSettlements(context.Background(), SettlementsOptions{\n\t\tLimit: 100,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"GetSettlements failed: %v\", err)\n\t}\n}\n\nfunc TestGetSubaccounts(t *testing.T) {\n\texpectedSubaccounts := []models.Subaccount{\n\t\t{SubaccountID: 1, Balance: 50000, AvailableBalance: 45000},\n\t\t{SubaccountID: 2, Balance: 25000, AvailableBalance: 25000},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/subaccounts\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.SubaccountsResponse{Subaccounts: expectedSubaccounts}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetSubaccounts(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetSubaccounts failed: %v\", err)\n\t}\n\n\tif len(result.Subaccounts) != 2 {\n\t\tt.Errorf(\"expected 2 subaccounts, got %d\", len(result.Subaccounts))\n\t}\n}\n\nfunc TestCreateSubaccount(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/subaccounts\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.Subaccount{\n\t\t\tSubaccountID: 3,\n\t\t\tBalance: 0,\n\t\t\tAvailableBalance: 0,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.CreateSubaccount(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"CreateSubaccount failed: %v\", err)\n\t}\n\n\tif result.SubaccountID != 3 {\n\t\tt.Errorf(\"expected subaccount ID 3, got %d\", result.SubaccountID)\n\t}\n}\n\nfunc TestGetTransfers(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\texpectedTransfers := []models.Transfer{\n\t\t{\n\t\t\tTransferID: \"transfer-1\",\n\t\t\tFromSubaccount: 1,\n\t\t\tToSubaccount: 2,\n\t\t\tAmount: 10000,\n\t\t\tCreatedTime: now,\n\t\t},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\t// BUG FIX: Correct path per Kalshi API spec\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/subaccounts/transfers\" {\n\t\t\tt.Errorf(\"unexpected path: %s, expected /trade-api/v2/portfolio/subaccounts/transfers\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.TransfersResponse{Transfers: expectedTransfers}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetTransfers(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetTransfers failed: %v\", err)\n\t}\n\n\tif len(result.Transfers) != 1 {\n\t\tt.Errorf(\"expected 1 transfer, got %d\", len(result.Transfers))\n\t}\n\tif result.Transfers[0].TransferID != \"transfer-1\" {\n\t\tt.Errorf(\"expected transfer ID 'transfer-1', got '%s'\", result.Transfers[0].TransferID)\n\t}\n}\n\nfunc TestTransfer(t *testing.T) {\n\tnow := time.Now().UTC().Truncate(time.Second)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t}\n\t\t// BUG FIX: Correct path per Kalshi API spec\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/subaccounts/transfers\" {\n\t\t\tt.Errorf(\"unexpected path: %s, expected /trade-api/v2/portfolio/subaccounts/transfers\", r.URL.Path)\n\t\t}\n\n\t\tvar req models.TransferRequest\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request: %v\", err)\n\t\t}\n\n\t\tif req.FromSubaccount != 1 {\n\t\t\tt.Errorf(\"expected from_subaccount 1, got %d\", req.FromSubaccount)\n\t\t}\n\t\tif req.ToSubaccount != 2 {\n\t\t\tt.Errorf(\"expected to_subaccount 2, got %d\", req.ToSubaccount)\n\t\t}\n\t\tif req.Amount != 5000 {\n\t\t\tt.Errorf(\"expected amount 5000, got %d\", req.Amount)\n\t\t}\n\n\t\tresp := models.Transfer{\n\t\t\tTransferID: \"new-transfer\",\n\t\t\tFromSubaccount: req.FromSubaccount,\n\t\t\tToSubaccount: req.ToSubaccount,\n\t\t\tAmount: req.Amount,\n\t\t\tCreatedTime: now,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.Transfer(context.Background(), models.TransferRequest{\n\t\tFromSubaccount: 1,\n\t\tToSubaccount: 2,\n\t\tAmount: 5000,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Transfer failed: %v\", err)\n\t}\n\n\tif result.TransferID != \"new-transfer\" {\n\t\tt.Errorf(\"expected transfer ID 'new-transfer', got '%s'\", result.TransferID)\n\t}\n\tif result.Amount != 5000 {\n\t\tt.Errorf(\"expected amount 5000, got %d\", result.Amount)\n\t}\n}\n\nfunc TestGetSubaccountBalances(t *testing.T) {\n\texpectedBalances := []models.SubaccountBalance{\n\t\t{SubaccountID: 1, Balance: 50000, AvailableBalance: 45000},\n\t\t{SubaccountID: 2, Balance: 25000, AvailableBalance: 25000},\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/subaccounts/balances\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.SubaccountBalancesResponse{Balances: expectedBalances}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetSubaccountBalances(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetSubaccountBalances failed: %v\", err)\n\t}\n\n\tif len(result.Balances) != 2 {\n\t\tt.Errorf(\"expected 2 balances, got %d\", len(result.Balances))\n\t}\n\tif result.Balances[0].SubaccountID != 1 {\n\t\tt.Errorf(\"expected subaccount ID 1, got %d\", result.Balances[0].SubaccountID)\n\t}\n}\n\nfunc TestGetRestingOrderValue(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/resting-order-value\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.RestingOrderValueResponse{RestingOrderValue: 150000}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetRestingOrderValue(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetRestingOrderValue failed: %v\", err)\n\t}\n\n\tif result.RestingOrderValue != 150000 {\n\t\tt.Errorf(\"expected resting order value 150000, got %d\", result.RestingOrderValue)\n\t}\n}\n\nfunc TestGetBalanceFullResponse(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/trade-api/v2/portfolio/balance\" {\n\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t}\n\n\t\tresp := models.BalanceResponse{\n\t\t\tBalance: 100000,\n\t\t\tPortfolioValue: 50000,\n\t\t\tUpdatedTs: 1700000000,\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\tresult, err := client.GetBalance(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"GetBalance failed: %v\", err)\n\t}\n\n\tif result.Balance != 100000 {\n\t\tt.Errorf(\"expected balance 100000, got %d\", result.Balance)\n\t}\n\tif result.PortfolioValue != 50000 {\n\t\tt.Errorf(\"expected portfolio_value 50000, got %d\", result.PortfolioValue)\n\t}\n\tif result.UpdatedTs != 1700000000 {\n\t\tt.Errorf(\"expected updated_ts 1700000000, got %d\", result.UpdatedTs)\n\t}\n}\n\nfunc TestPortfolioAPIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"error\": \"Unauthorized\",\n\t\t\t\"code\": \"UNAUTHORIZED\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := createTestClient(t, server.URL)\n\t_, err := client.GetBalance(context.Background())\n\tif err == nil {\n\t\tt.Fatal(\"expected error for unauthorized request\")\n\t}\n\n\tapiErr, ok := err.(*APIError)\n\tif !ok {\n\t\tt.Fatalf(\"expected APIError, got %T\", err)\n\t}\n\tif apiErr.StatusCode != 401 {\n\t\tt.Errorf(\"expected status 401, got %d\", apiErr.StatusCode)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":15231,"content_sha256":"918fe92d7271944f6a5092229db455a30ef535594f8051497e651f53089a4c9a"},{"filename":"internal/api/portfolio.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nconst portfolioBasePath = TradeAPIPrefix + \"/portfolio\"\n\n// PositionsOptions contains options for listing positions\ntype PositionsOptions struct {\n\tTicker string\n\tEventTicker string\n\tCursor string\n\tLimit int\n\tSettlementStatus string\n\tCountFilter string\n\tSubaccountID int\n}\n\n// toQueryParams converts PositionsOptions to query parameters\nfunc (o PositionsOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Ticker != \"\" {\n\t\tparams[\"ticker\"] = o.Ticker\n\t}\n\tif o.EventTicker != \"\" {\n\t\tparams[\"event_ticker\"] = o.EventTicker\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\tif o.SettlementStatus != \"\" {\n\t\tparams[\"settlement_status\"] = o.SettlementStatus\n\t}\n\tif o.CountFilter != \"\" {\n\t\tparams[\"count_filter\"] = o.CountFilter\n\t}\n\tif o.SubaccountID > 0 {\n\t\tparams[\"subaccount_id\"] = strconv.Itoa(o.SubaccountID)\n\t}\n\treturn params\n}\n\n// FillsOptions contains options for listing fills\ntype FillsOptions struct {\n\tTicker string\n\tOrderID string\n\tCursor string\n\tLimit int\n\tMinTS int64\n\tMaxTS int64\n\tSubaccountID int\n}\n\n// toQueryParams converts FillsOptions to query parameters\nfunc (o FillsOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Ticker != \"\" {\n\t\tparams[\"ticker\"] = o.Ticker\n\t}\n\tif o.OrderID != \"\" {\n\t\tparams[\"order_id\"] = o.OrderID\n\t}\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\tif o.MinTS > 0 {\n\t\tparams[\"min_ts\"] = strconv.FormatInt(o.MinTS, 10)\n\t}\n\tif o.MaxTS > 0 {\n\t\tparams[\"max_ts\"] = strconv.FormatInt(o.MaxTS, 10)\n\t}\n\tif o.SubaccountID > 0 {\n\t\tparams[\"subaccount_id\"] = strconv.Itoa(o.SubaccountID)\n\t}\n\treturn params\n}\n\n// SettlementsOptions contains options for listing settlements\ntype SettlementsOptions struct {\n\tCursor string\n\tLimit int\n\tSubaccountID int\n}\n\n// toQueryParams converts SettlementsOptions to query parameters\nfunc (o SettlementsOptions) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif o.Cursor != \"\" {\n\t\tparams[\"cursor\"] = o.Cursor\n\t}\n\tif o.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(o.Limit)\n\t}\n\tif o.SubaccountID > 0 {\n\t\tparams[\"subaccount_id\"] = strconv.Itoa(o.SubaccountID)\n\t}\n\treturn params\n}\n\n// GetBalance returns the account balance\nfunc (c *Client) GetBalance(ctx context.Context) (*models.BalanceResponse, error) {\n\tpath := portfolioBasePath + \"/balance\"\n\n\tvar result models.BalanceResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetPositions returns market positions based on the provided options\nfunc (c *Client) GetPositions(ctx context.Context, opts PositionsOptions) (*models.PositionsResponse, error) {\n\tpath := portfolioBasePath + \"/positions\" + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.PositionsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetFills returns trade fills based on the provided options\nfunc (c *Client) GetFills(ctx context.Context, opts FillsOptions) (*models.FillsResponse, error) {\n\tpath := portfolioBasePath + \"/fills\" + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.FillsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetSettlements returns settlements based on the provided options\nfunc (c *Client) GetSettlements(ctx context.Context, opts SettlementsOptions) (*models.SettlementsResponse, error) {\n\tpath := portfolioBasePath + \"/settlements\" + BuildQueryString(opts.toQueryParams())\n\n\tvar result models.SettlementsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetSubaccounts returns all subaccounts\nfunc (c *Client) GetSubaccounts(ctx context.Context) (*models.SubaccountsResponse, error) {\n\tpath := portfolioBasePath + \"/subaccounts\"\n\n\tvar result models.SubaccountsResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// CreateSubaccount creates a new subaccount\nfunc (c *Client) CreateSubaccount(ctx context.Context) (*models.Subaccount, error) {\n\tpath := portfolioBasePath + \"/subaccounts\"\n\n\tvar result models.Subaccount\n\tif err := c.PostJSON(ctx, path, nil, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetTransfers returns all subaccount transfers\nfunc (c *Client) GetTransfers(ctx context.Context) (*models.TransfersResponse, error) {\n\tpath := portfolioBasePath + \"/subaccounts/transfers\"\n\n\tvar result models.TransfersResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// Transfer creates a transfer between subaccounts\nfunc (c *Client) Transfer(ctx context.Context, req models.TransferRequest) (*models.Transfer, error) {\n\tpath := portfolioBasePath + \"/subaccounts/transfers\"\n\n\tvar result models.Transfer\n\tif err := c.PostJSON(ctx, path, req, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetSubaccountBalances returns all subaccount balances\nfunc (c *Client) GetSubaccountBalances(ctx context.Context) (*models.SubaccountBalancesResponse, error) {\n\tpath := portfolioBasePath + \"/subaccounts/balances\"\n\n\tvar result models.SubaccountBalancesResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n\n// GetRestingOrderValue returns the total resting order value (FCM only)\nfunc (c *Client) GetRestingOrderValue(ctx context.Context) (*models.RestingOrderValueResponse, error) {\n\tpath := portfolioBasePath + \"/resting-order-value\"\n\n\tvar result models.RestingOrderValueResponse\n\tif err := c.GetJSON(ctx, path, &result); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5992,"content_sha256":"91698edf82cd95379f5374b1edc1562f1cfc59e616fd6c64f8fff73ddd06e6cb"},{"filename":"internal/api/search_test.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 1: Write FAILING tests FIRST (RED)\n// =============================================================================\n\nfunc TestGetSportsFilters(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.SportsFiltersResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns sports filters successfully\",\n\t\t\tserverResponse: models.SportsFiltersResponse{\n\t\t\t\tFilters: []models.SportsFilter{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"nfl-teams\",\n\t\t\t\t\t\tName: \"NFL Teams\",\n\t\t\t\t\t\tSport: \"football\",\n\t\t\t\t\t\tLeague: \"NFL\",\n\t\t\t\t\t\tCategory: \"team\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"nba-teams\",\n\t\t\t\t\t\tName: \"NBA Teams\",\n\t\t\t\t\t\tSport: \"basketball\",\n\t\t\t\t\t\tLeague: \"NBA\",\n\t\t\t\t\t\tCategory: \"team\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty filters\",\n\t\t\tserverResponse: models.SportsFiltersResponse{\n\t\t\t\tFilters: []models.SportsFilter{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.SportsFiltersResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/search/sports/filters\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := searchTestClient(t, server.URL)\n\t\t\tresp, err := client.GetSportsFilters(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Filters) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d filters, got %d\", tt.wantCount, len(resp.Filters))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetSearchTags(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.TagsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns tags successfully\",\n\t\t\tserverResponse: models.TagsResponse{\n\t\t\t\tMappings: []models.TagMapping{\n\t\t\t\t\t{\n\t\t\t\t\t\tCategory: \"politics\",\n\t\t\t\t\t\tTags: []string{\"election\", \"congress\", \"senate\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCategory: \"economics\",\n\t\t\t\t\t\tTags: []string{\"gdp\", \"inflation\", \"employment\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty tags\",\n\t\t\tserverResponse: models.TagsResponse{\n\t\t\t\tMappings: []models.TagMapping{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.TagsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/search/tags\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := searchTestClient(t, server.URL)\n\t\t\tresp, err := client.GetSearchTags(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Mappings) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d mappings, got %d\", tt.wantCount, len(resp.Mappings))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetStructuredTarget(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ttargetID string\n\t\tserverResponse models.StructuredTargetResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"returns target successfully\",\n\t\t\ttargetID: \"target-123\",\n\t\t\tserverResponse: models.StructuredTargetResponse{\n\t\t\t\tTarget: models.StructuredTarget{\n\t\t\t\t\tID: \"target-123\",\n\t\t\t\t\tName: \"Bitcoin Price Target\",\n\t\t\t\t\tDescription: \"Bitcoin reaches $100,000\",\n\t\t\t\t\tType: \"price_target\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"handles not found\",\n\t\t\ttargetID: \"invalid-id\",\n\t\t\tserverResponse: models.StructuredTargetResponse{},\n\t\t\tserverStatus: http.StatusNotFound,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/structured-targets/\" + tt.targetID\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := searchTestClient(t, server.URL)\n\t\t\tresp, err := client.GetStructuredTarget(context.Background(), tt.targetID)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.ID != tt.targetID {\n\t\t\t\tt.Errorf(\"expected target ID %q, got %q\", tt.targetID, resp.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestListStructuredTargets(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tparams ListStructuredTargetsParams\n\t\tserverResponse models.StructuredTargetsResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns targets successfully\",\n\t\t\tparams: ListStructuredTargetsParams{Limit: 100},\n\t\t\tserverResponse: models.StructuredTargetsResponse{\n\t\t\t\tTargets: []models.StructuredTarget{\n\t\t\t\t\t{ID: \"target-1\", Name: \"Target 1\"},\n\t\t\t\t\t{ID: \"target-2\", Name: \"Target 2\"},\n\t\t\t\t},\n\t\t\t\tCursor: \"next-cursor\",\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns targets with pagination\",\n\t\t\tparams: ListStructuredTargetsParams{Cursor: \"page-2\", Limit: 50},\n\t\t\tserverResponse: models.StructuredTargetsResponse{\n\t\t\t\tTargets: []models.StructuredTarget{\n\t\t\t\t\t{ID: \"target-3\", Name: \"Target 3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tparams: ListStructuredTargetsParams{},\n\t\t\tserverResponse: models.StructuredTargetsResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/structured-targets\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := searchTestClient(t, server.URL)\n\t\t\tresp, err := client.ListStructuredTargets(context.Background(), tt.params)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Targets) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d targets, got %d\", tt.wantCount, len(resp.Targets))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetIncentives(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tserverResponse models.IncentivesResponse\n\t\tserverStatus int\n\t\twantErr bool\n\t\twantCount int\n\t}{\n\t\t{\n\t\t\tname: \"returns incentives successfully\",\n\t\t\tserverResponse: models.IncentivesResponse{\n\t\t\t\tIncentives: []models.Incentive{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"incentive-1\",\n\t\t\t\t\t\tName: \"Referral Bonus\",\n\t\t\t\t\t\tDescription: \"Get $20 for referring a friend\",\n\t\t\t\t\t\tType: \"referral\",\n\t\t\t\t\t\tValue: 20.0,\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"incentive-2\",\n\t\t\t\t\t\tName: \"Welcome Bonus\",\n\t\t\t\t\t\tDescription: \"New user welcome bonus\",\n\t\t\t\t\t\tType: \"signup\",\n\t\t\t\t\t\tValue: 10.0,\n\t\t\t\t\t\tStatus: \"active\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"returns empty incentives\",\n\t\t\tserverResponse: models.IncentivesResponse{\n\t\t\t\tIncentives: []models.Incentive{},\n\t\t\t},\n\t\t\tserverStatus: http.StatusOK,\n\t\t\twantErr: false,\n\t\t\twantCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"handles server error\",\n\t\t\tserverResponse: models.IncentivesResponse{},\n\t\t\tserverStatus: http.StatusInternalServerError,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tt.Errorf(\"expected GET request, got %s\", r.Method)\n\t\t\t\t}\n\n\t\t\t\texpectedPath := TradeAPIPrefix + \"/incentives\"\n\t\t\t\tif r.URL.Path != expectedPath {\n\t\t\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t\t\t}\n\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(tt.serverStatus)\n\t\t\t\tif tt.serverStatus == http.StatusOK {\n\t\t\t\t\tjson.NewEncoder(w).Encode(tt.serverResponse)\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tclient := searchTestClient(t, server.URL)\n\t\t\tresp, err := client.GetIncentives(context.Background())\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(resp.Incentives) != tt.wantCount {\n\t\t\t\tt.Errorf(\"expected %d incentives, got %d\", tt.wantCount, len(resp.Incentives))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// searchTestClient creates a test client for search tests\nfunc searchTestClient(t *testing.T, serverURL string) *Client {\n\tt.Helper()\n\tclient := NewClient(nil, nil)\n\tclient.SetBaseURL(serverURL)\n\treturn client\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":11300,"content_sha256":"b61c2c5992d4e44e8fcf9582e4129aa32b983cbbf23ce6ca46017992752ad197"},{"filename":"internal/api/search.go","content":"package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\n// =============================================================================\n// TDD Step 2: Implement to make tests pass (GREEN)\n// =============================================================================\n\n// ListStructuredTargetsParams contains parameters for listing structured targets\ntype ListStructuredTargetsParams struct {\n\tLimit int\n\tCursor string\n}\n\n// toQueryParams converts ListStructuredTargetsParams to query parameters\nfunc (p ListStructuredTargetsParams) toQueryParams() map[string]string {\n\tparams := make(map[string]string)\n\tif p.Limit > 0 {\n\t\tparams[\"limit\"] = strconv.Itoa(p.Limit)\n\t}\n\tif p.Cursor != \"\" {\n\t\tparams[\"cursor\"] = p.Cursor\n\t}\n\treturn params\n}\n\n// GetSportsFilters retrieves sports filtering options\nfunc (c *Client) GetSportsFilters(ctx context.Context) (*models.SportsFiltersResponse, error) {\n\tpath := TradeAPIPrefix + \"/search/sports/filters\"\n\n\tvar result models.SportsFiltersResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get sports filters: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetSearchTags retrieves series categories to tags mapping\nfunc (c *Client) GetSearchTags(ctx context.Context) (*models.TagsResponse, error) {\n\tpath := TradeAPIPrefix + \"/search/tags\"\n\n\tvar result models.TagsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get search tags: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetStructuredTarget retrieves a single structured target by ID\nfunc (c *Client) GetStructuredTarget(ctx context.Context, targetID string) (*models.StructuredTarget, error) {\n\tif targetID == \"\" {\n\t\treturn nil, fmt.Errorf(\"target_id is required\")\n\t}\n\n\tpath := TradeAPIPrefix + \"/structured-targets/\" + targetID\n\n\tvar result models.StructuredTargetResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get structured target: %w\", err)\n\t}\n\n\treturn &result.Target, nil\n}\n\n// ListStructuredTargets retrieves structured targets with pagination\n// Limit must be between 1 and 2000 per page\nfunc (c *Client) ListStructuredTargets(ctx context.Context, params ListStructuredTargetsParams) (*models.StructuredTargetsResponse, error) {\n\t// Enforce limit bounds per API spec\n\tif params.Limit \u003c 1 {\n\t\tparams.Limit = 100 // Default\n\t}\n\tif params.Limit > 2000 {\n\t\tparams.Limit = 2000\n\t}\n\n\tpath := TradeAPIPrefix + \"/structured-targets\" + BuildQueryString(params.toQueryParams())\n\n\tvar result models.StructuredTargetsResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list structured targets: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// GetIncentives retrieves available rewards programs\nfunc (c *Client) GetIncentives(ctx context.Context) (*models.IncentivesResponse, error) {\n\tpath := TradeAPIPrefix + \"/incentives\"\n\n\tvar result models.IncentivesResponse\n\tif err := c.DoRequest(ctx, \"GET\", path, nil, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get incentives: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3196,"content_sha256":"829c82de7cc20aa1d0a62f0a7af97d435df040eca4dd6e05546642d66c76aeca"},{"filename":"internal/cmd/auth.go","content":"package cmd\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n)\n\nvar authCmd = &cobra.Command{\n\tUse: \"auth\",\n\tShort: \"Manage authentication and API keys\",\n\tLong: `Manage authentication credentials and API keys for the Kalshi API.\n\nAPI keys are provisioned by Kalshi through their dashboard.\n\nThe auth commands allow you to:\n - Log in with your Kalshi-provisioned API credentials\n - Log out and clear stored credentials\n - Check authentication status\n - Manage API keys`,\n}\n\nvar loginCmd = &cobra.Command{\n\tUse: \"login\",\n\tShort: \"Authenticate with Kalshi\",\n\tLong: `Authenticate with Kalshi using your API credentials.\n\nAPI keys are provisioned by Kalshi. To get your credentials:\n 1. Go to https://kalshi.com/account/api (or demo: https://demo.kalshi.com/account/api)\n 2. Click \"Generate API Key\"\n 3. Save the API Key ID and Private Key (shown only once!)\n\nInteractive mode:\n kalshi-cli auth login\n\nNon-interactive mode (for bots/automation):\n kalshi-cli auth login --api-key-id \u003cid> --private-key-file /path/to/key.pem\n kalshi-cli auth login --api-key-id \u003cid> --private-key \"$(cat key.pem)\"\n\nEnvironment variables:\n KALSHI_API_KEY_ID - API Key ID\n KALSHI_PRIVATE_KEY - Private key PEM content`,\n\tRunE: runLogin,\n}\n\nvar logoutCmd = &cobra.Command{\n\tUse: \"logout\",\n\tShort: \"Clear stored credentials\",\n\tLong: `Remove stored API credentials from the system keyring.`,\n\tRunE: runLogout,\n}\n\nvar statusCmd = &cobra.Command{\n\tUse: \"status\",\n\tShort: \"Show authentication status\",\n\tLong: `Display the current authentication status and environment.`,\n\tRunE: runStatus,\n}\n\nvar keysCmd = &cobra.Command{\n\tUse: \"keys\",\n\tShort: \"Manage API keys\",\n\tLong: `List, create, and delete API keys for your Kalshi account.`,\n}\n\nvar keysListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List API keys\",\n\tLong: `List all API keys associated with your Kalshi account.`,\n\tRunE: runKeysList,\n}\n\nvar keysCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create a new API key\",\n\tLong: `Create a new API key for your Kalshi account.`,\n\tRunE: runKeysCreate,\n}\n\nvar keysDeleteCmd = &cobra.Command{\n\tUse: \"delete \u003cid>\",\n\tShort: \"Delete an API key\",\n\tLong: `Delete an API key by its ID.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runKeysDelete,\n}\n\nvar (\n\tkeyName string\n\tloginAPIKeyID string\n\tloginPrivKey string\n\tloginPrivKeyFile string\n)\n\nfunc init() {\n\trootCmd.AddCommand(authCmd)\n\n\tauthCmd.AddCommand(loginCmd)\n\tauthCmd.AddCommand(logoutCmd)\n\tauthCmd.AddCommand(statusCmd)\n\tauthCmd.AddCommand(keysCmd)\n\n\tkeysCmd.AddCommand(keysListCmd)\n\tkeysCmd.AddCommand(keysCreateCmd)\n\tkeysCmd.AddCommand(keysDeleteCmd)\n\n\t// Login flags for non-interactive/bot usage\n\tloginCmd.Flags().StringVar(&loginAPIKeyID, \"api-key-id\", \"\", \"API Key ID from Kalshi (or set KALSHI_API_KEY_ID env var)\")\n\tloginCmd.Flags().StringVar(&loginPrivKey, \"private-key\", \"\", \"Private key PEM content (or set KALSHI_PRIVATE_KEY env var)\")\n\tloginCmd.Flags().StringVar(&loginPrivKeyFile, \"private-key-file\", \"\", \"Path to private key PEM file\")\n\n\tkeysCreateCmd.Flags().StringVar(&keyName, \"name\", \"\", \"name for the new API key\")\n}\n\nfunc runLogin(cmd *cobra.Command, args []string) error {\n\tkeyring, err := config.NewKeyringStore()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to access keyring: %w\", err)\n\t}\n\n\t// Resolve credentials from flags, env vars, or interactive input\n\tapiKeyID, privateKeyPEM, err := resolveLoginCredentials(keyring)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcreds := config.Credentials{\n\t\tAPIKeyID: apiKeyID,\n\t\tPrivateKey: privateKeyPEM,\n\t}\n\n\t// Validate the private key can be parsed\n\t_, err = api.NewSignerFromPEM(creds.APIKeyID, creds.PrivateKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid private key format: %w\", err)\n\t}\n\n\tif err := keyring.SaveCredentials(creds); err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\tfmt.Println()\n\tfmt.Println(\"Testing authentication...\")\n\n\tclient, err := createAuthenticatedClient(creds)\n\tif err != nil {\n\t\tif deleteErr := keyring.DeleteCredentials(); deleteErr != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Warning: failed to clean up credentials: %v\\n\", deleteErr)\n\t\t}\n\t\treturn fmt.Errorf(\"authentication failed: %w\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tstatus, err := client.GetExchangeStatus(ctx)\n\tif err != nil {\n\t\tif deleteErr := keyring.DeleteCredentials(); deleteErr != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Warning: failed to clean up credentials: %v\\n\", deleteErr)\n\t\t}\n\t\treturn fmt.Errorf(\"authentication test failed: %w\", err)\n\t}\n\n\tfmt.Println()\n\tPrintSuccess(\"Authentication successful!\")\n\tfmt.Printf(\"Exchange Active: %v\\n\", status.ExchangeActive)\n\tfmt.Printf(\"Trading Active: %v\\n\", status.TradingActive)\n\tfmt.Printf(\"Environment: %s\\n\", cfg.Environment())\n\n\treturn nil\n}\n\n// resolveLoginCredentials gets credentials from flags, env vars, or interactive input\nfunc resolveLoginCredentials(keyring *config.KeyringStore) (apiKeyID, privateKeyPEM string, err error) {\n\t// Check for existing credentials\n\tif keyring.HasCredentials() {\n\t\texistingCreds, err := keyring.GetCredentials()\n\t\tif err == nil && existingCreds != nil {\n\t\t\tfmt.Println(ui.WarningStyle.Render(\"You are already logged in.\"))\n\t\t\tfmt.Printf(\"API Key ID: %s\\n\", existingCreds.APIKeyID)\n\t\t\tfmt.Println()\n\n\t\t\tif !SkipConfirmation() {\n\t\t\t\tfmt.Print(\"Do you want to log out and enter new credentials? [y/N]: \")\n\t\t\t\treader := bufio.NewReader(os.Stdin)\n\t\t\t\tresponse, _ := reader.ReadString('\\n')\n\t\t\t\tresponse = strings.TrimSpace(strings.ToLower(response))\n\t\t\t\tif response != \"y\" && response != \"yes\" {\n\t\t\t\t\treturn \"\", \"\", fmt.Errorf(\"login cancelled\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := keyring.DeleteCredentials(); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"failed to clear existing credentials: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Try to get API Key ID from flag, then env var\n\tapiKeyID = loginAPIKeyID\n\tif apiKeyID == \"\" {\n\t\tapiKeyID = os.Getenv(\"KALSHI_API_KEY_ID\")\n\t}\n\n\t// Try to get private key from file flag, string flag, then env var\n\tif loginPrivKeyFile != \"\" {\n\t\tcontent, err := os.ReadFile(loginPrivKeyFile)\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"failed to read private key file: %w\", err)\n\t\t}\n\t\tprivateKeyPEM = string(content)\n\t} else if loginPrivKey != \"\" {\n\t\tprivateKeyPEM = loginPrivKey\n\t} else {\n\t\tprivateKeyPEM = os.Getenv(\"KALSHI_PRIVATE_KEY\")\n\t}\n\n\t// If we have both from non-interactive sources, we're done\n\tif apiKeyID != \"\" && privateKeyPEM != \"\" {\n\t\tfmt.Println(ui.TitleStyle.Render(\"Kalshi API Authentication (non-interactive)\"))\n\t\tfmt.Printf(\"API Key ID: %s\\n\", apiKeyID)\n\t\treturn apiKeyID, privateKeyPEM, nil\n\t}\n\n\t// Interactive mode\n\tfmt.Println(ui.TitleStyle.Render(\"Kalshi API Authentication\"))\n\tfmt.Println()\n\tfmt.Println(\"API keys are provisioned by Kalshi. If you don't have credentials yet:\")\n\tfmt.Println(\" 1. Go to https://kalshi.com/account/api (or demo: https://demo.kalshi.com/account/api)\")\n\tfmt.Println(\" 2. Click 'Generate API Key'\")\n\tfmt.Println(\" 3. Save the API Key ID and Private Key (shown only once!)\")\n\tfmt.Println()\n\n\treader := bufio.NewReader(os.Stdin)\n\n\t// Get API Key ID if not provided\n\tif apiKeyID == \"\" {\n\t\tfmt.Print(\"Enter your API Key ID: \")\n\t\tinput, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"failed to read input: %w\", err)\n\t\t}\n\t\tapiKeyID = strings.TrimSpace(input)\n\t}\n\n\tif apiKeyID == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"API Key ID is required\")\n\t}\n\n\t// Get Private Key if not provided\n\tif privateKeyPEM == \"\" {\n\t\tfmt.Println()\n\t\tfmt.Println(\"Enter your Private Key (paste PEM content or provide file path):\")\n\t\tfmt.Println(\"(If pasting, enter the full PEM including BEGIN/END lines, then press Enter twice)\")\n\t\tfmt.Println()\n\n\t\tprivateKeyPEM, err = readPrivateKeyInput(reader)\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"failed to read private key: %w\", err)\n\t\t}\n\t}\n\n\tif privateKeyPEM == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"private key is required\")\n\t}\n\n\treturn apiKeyID, privateKeyPEM, nil\n}\n\n// readPrivateKeyInput reads a private key from stdin (multi-line PEM) or a file path\nfunc readPrivateKeyInput(reader *bufio.Reader) (string, error) {\n\tvar lines []string\n\temptyLineCount := 0\n\n\tfor {\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\ttrimmed := strings.TrimSpace(line)\n\n\t\t// Check if it's a file path (first non-empty line, doesn't start with -----)\n\t\tif len(lines) == 0 && trimmed != \"\" && !strings.HasPrefix(trimmed, \"-----\") {\n\t\t\t// Try to read as file path\n\t\t\tif content, err := os.ReadFile(trimmed); err == nil {\n\t\t\t\treturn string(content), nil\n\t\t\t}\n\t\t\t// Not a valid file, treat as start of PEM content\n\t\t}\n\n\t\t// Track empty lines to detect end of input\n\t\tif trimmed == \"\" {\n\t\t\temptyLineCount++\n\t\t\tif emptyLineCount >= 2 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t} else {\n\t\t\temptyLineCount = 0\n\t\t}\n\n\t\tlines = append(lines, line)\n\n\t\t// Check if we've reached the end of a PEM block\n\t\tif strings.HasPrefix(trimmed, \"-----END\") {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tresult := strings.Join(lines, \"\")\n\treturn strings.TrimSpace(result), nil\n}\n\nfunc runLogout(cmd *cobra.Command, args []string) error {\n\tkeyring, err := config.NewKeyringStore()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to access keyring: %w\", err)\n\t}\n\n\tif !keyring.HasCredentials() {\n\t\tfmt.Println(\"You are not logged in.\")\n\t\treturn nil\n\t}\n\n\tif !SkipConfirmation() {\n\t\tfmt.Print(\"Are you sure you want to log out? [y/N]: \")\n\t\treader := bufio.NewReader(os.Stdin)\n\t\tresponse, _ := reader.ReadString('\\n')\n\t\tresponse = strings.TrimSpace(strings.ToLower(response))\n\t\tif response != \"y\" && response != \"yes\" {\n\t\t\tfmt.Println(\"Logout cancelled.\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err := keyring.DeleteCredentials(); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete credentials: %w\", err)\n\t}\n\n\tPrintSuccess(\"Successfully logged out.\")\n\treturn nil\n}\n\nfunc runStatus(cmd *cobra.Command, args []string) error {\n\tkeyring, err := config.NewKeyringStore()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to access keyring: %w\", err)\n\t}\n\n\tcreds, err := keyring.GetCredentials()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\n\tstatusData := authStatusData{\n\t\tLoggedIn: creds != nil,\n\t\tEnvironment: cfg.Environment(),\n\t}\n\n\tif creds != nil {\n\t\tstatusData.APIKeyID = creds.APIKeyID\n\n\t\tclient, err := createAuthenticatedClient(*creds)\n\t\tif err == nil {\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\texchangeStatus, err := client.GetExchangeStatus(ctx)\n\t\t\tif err == nil {\n\t\t\t\tstatusData.ExchangeActive = exchangeStatus.ExchangeActive\n\t\t\t\tstatusData.TradingActive = exchangeStatus.TradingActive\n\t\t\t\tstatusData.Authenticated = true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderStatusTable(statusData) },\n\t\tstatusData,\n\t\tfunc() { renderStatusPlain(statusData) },\n\t)\n}\n\ntype authStatusData struct {\n\tLoggedIn bool `json:\"logged_in\"`\n\tAPIKeyID string `json:\"api_key_id,omitempty\"`\n\tEnvironment string `json:\"environment\"`\n\tAuthenticated bool `json:\"authenticated\"`\n\tExchangeActive bool `json:\"exchange_active\"`\n\tTradingActive bool `json:\"trading_active\"`\n}\n\nfunc renderStatusTable(data authStatusData) {\n\tvar pairs [][]string\n\n\tloggedInStatus := ui.ErrorStyle.Render(\"No\")\n\tif data.LoggedIn {\n\t\tloggedInStatus = ui.SuccessStyle.Render(\"Yes\")\n\t}\n\tpairs = append(pairs, []string{\"Logged In\", loggedInStatus})\n\n\tif data.APIKeyID != \"\" {\n\t\tpairs = append(pairs, []string{\"API Key ID\", data.APIKeyID})\n\t}\n\n\tpairs = append(pairs, []string{\"Environment\", data.Environment})\n\n\tif data.LoggedIn {\n\t\tauthStatus := ui.ErrorStyle.Render(\"Failed\")\n\t\tif data.Authenticated {\n\t\t\tauthStatus = ui.SuccessStyle.Render(\"Valid\")\n\t\t}\n\t\tpairs = append(pairs, []string{\"Authentication\", authStatus})\n\n\t\tif data.Authenticated {\n\t\t\texchangeStatus := \"Inactive\"\n\t\t\tif data.ExchangeActive {\n\t\t\t\texchangeStatus = \"Active\"\n\t\t\t}\n\t\t\tpairs = append(pairs, []string{\"Exchange\", exchangeStatus})\n\n\t\t\ttradingStatus := \"Inactive\"\n\t\t\tif data.TradingActive {\n\t\t\t\ttradingStatus = \"Active\"\n\t\t\t}\n\t\t\tpairs = append(pairs, []string{\"Trading\", tradingStatus})\n\t\t}\n\t}\n\n\tui.RenderKeyValue(pairs)\n}\n\nfunc renderStatusPlain(data authStatusData) {\n\tif data.LoggedIn {\n\t\tfmt.Printf(\"logged_in=true\\n\")\n\t\tfmt.Printf(\"api_key_id=%s\\n\", data.APIKeyID)\n\t\tfmt.Printf(\"environment=%s\\n\", data.Environment)\n\t\tfmt.Printf(\"authenticated=%v\\n\", data.Authenticated)\n\t\tif data.Authenticated {\n\t\t\tfmt.Printf(\"exchange_active=%v\\n\", data.ExchangeActive)\n\t\t\tfmt.Printf(\"trading_active=%v\\n\", data.TradingActive)\n\t\t}\n\t} else {\n\t\tfmt.Printf(\"logged_in=false\\n\")\n\t\tfmt.Printf(\"environment=%s\\n\", data.Environment)\n\t}\n}\n\nfunc runKeysList(cmd *cobra.Command, args []string) error {\n\tclient, err := getAuthenticatedClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tkeys, err := client.ListAPIKeys(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list API keys: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderKeysTable(keys) },\n\t\tkeys,\n\t\tfunc() { renderKeysPlain(keys) },\n\t)\n}\n\nfunc renderKeysTable(keys []api.APIKey) {\n\theaders := []string{\"ID\", \"Name\", \"Created\", \"Expires\", \"Scopes\"}\n\tvar rows [][]string\n\n\tfor _, key := range keys {\n\t\texpires := \"-\"\n\t\tif !key.ExpiresTime.IsZero() {\n\t\t\texpires = key.ExpiresTime.Format(\"2006-01-02\")\n\t\t}\n\t\tscopes := strings.Join(key.Scopes, \", \")\n\t\tif scopes == \"\" {\n\t\t\tscopes = \"-\"\n\t\t}\n\t\trows = append(rows, []string{\n\t\t\tkey.ID,\n\t\t\tkey.Name,\n\t\t\tkey.CreatedTime.Format(\"2006-01-02\"),\n\t\t\texpires,\n\t\t\tscopes,\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderKeysPlain(keys []api.APIKey) {\n\tfor _, key := range keys {\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\n\", key.ID, key.Name, key.CreatedTime.Format(\"2006-01-02\"))\n\t}\n}\n\nfunc runKeysCreate(cmd *cobra.Command, args []string) error {\n\tclient, err := getAuthenticatedClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq := api.CreateAPIKeyRequest{\n\t\tName: keyName,\n\t}\n\n\tresp, err := client.CreateAPIKey(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create API key: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderKeyCreatedTable(resp) },\n\t\tresp,\n\t\tfunc() { renderKeyCreatedPlain(resp) },\n\t)\n}\n\nfunc renderKeyCreatedTable(resp *api.CreateAPIKeyResponse) {\n\tPrintSuccess(\"API key created successfully!\")\n\tfmt.Println()\n\n\tpairs := [][]string{\n\t\t{\"ID\", resp.APIKey.ID},\n\t\t{\"Name\", resp.APIKey.Name},\n\t\t{\"Created\", resp.APIKey.CreatedTime.Format(\"2006-01-02 15:04:05\")},\n\t}\n\tui.RenderKeyValue(pairs)\n\n\tfmt.Println()\n\tfmt.Println(ui.WarningStyle.Render(\"IMPORTANT: Save the private key below. It will not be shown again!\"))\n\tfmt.Println()\n\tfmt.Println(resp.PrivateKey)\n}\n\nfunc renderKeyCreatedPlain(resp *api.CreateAPIKeyResponse) {\n\tfmt.Printf(\"id=%s\\n\", resp.APIKey.ID)\n\tfmt.Printf(\"name=%s\\n\", resp.APIKey.Name)\n\tfmt.Printf(\"private_key=%s\\n\", resp.PrivateKey)\n}\n\nfunc runKeysDelete(cmd *cobra.Command, args []string) error {\n\tkeyID := args[0]\n\n\tif !SkipConfirmation() {\n\t\tfmt.Printf(\"Are you sure you want to delete API key '%s'? [y/N]: \", keyID)\n\t\treader := bufio.NewReader(os.Stdin)\n\t\tresponse, _ := reader.ReadString('\\n')\n\t\tresponse = strings.TrimSpace(strings.ToLower(response))\n\t\tif response != \"y\" && response != \"yes\" {\n\t\t\tfmt.Println(\"Delete cancelled.\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tclient, err := getAuthenticatedClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := client.DeleteAPIKey(ctx, keyID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete API key: %w\", err)\n\t}\n\n\tresult := map[string]interface{}{\n\t\t\"deleted\": true,\n\t\t\"id\": keyID,\n\t}\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { PrintSuccess(fmt.Sprintf(\"API key '%s' deleted successfully.\", keyID)) },\n\t\tresult,\n\t\tfunc() { fmt.Printf(\"deleted=%s\\n\", keyID) },\n\t)\n}\n\nfunc getAuthenticatedClient() (*api.Client, error) {\n\tkeyring, err := config.NewKeyringStore()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to access keyring: %w\", err)\n\t}\n\n\tcreds, err := keyring.GetCredentials()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\n\tif creds == nil {\n\t\treturn nil, fmt.Errorf(\"not logged in. Run 'kalshi-cli auth login' first\")\n\t}\n\n\treturn createAuthenticatedClient(*creds)\n}\n\nfunc createAuthenticatedClient(creds config.Credentials) (*api.Client, error) {\n\tsigner, err := api.NewSignerFromPEM(creds.APIKeyID, creds.PrivateKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create signer: %w\", err)\n\t}\n\n\tclient := api.NewClient(cfg, signer)\n\treturn client, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":16821,"content_sha256":"3c94107779f294ac8c0202fd30f1a4773dda2f1b8f8ac203de774b6951789593"},{"filename":"internal/cmd/config.go","content":"package cmd\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n)\n\nvar validConfigKeys = map[string]struct {\n\tdescription string\n\tvalidate func(string) error\n}{\n\t\"output.format\": {\n\t\tdescription: \"Output format (table, json, plain)\",\n\t\tvalidate: validateOutputFormat,\n\t},\n\t\"output.color\": {\n\t\tdescription: \"Enable colored output (true, false)\",\n\t\tvalidate: validateBool,\n\t},\n\t\"defaults.limit\": {\n\t\tdescription: \"Default limit for list commands (number)\",\n\t\tvalidate: validatePositiveInt,\n\t},\n}\n\nvar configCmd = &cobra.Command{\n\tUse: \"config\",\n\tShort: \"Manage configuration settings\",\n\tLong: `Manage kalshi-cli configuration settings.\n\nConfiguration is stored in ~/.kalshi/config.yaml.\n\nAvailable configuration keys:\n output.format Output format (table, json, plain)\n output.color Enable colored output (true, false)\n defaults.limit Default limit for list commands (number)`,\n}\n\nvar configShowCmd = &cobra.Command{\n\tUse: \"show\",\n\tShort: \"Show current configuration\",\n\tLong: `Display all current configuration settings.`,\n\tRunE: runConfigShow,\n}\n\nvar configGetCmd = &cobra.Command{\n\tUse: \"get \u003ckey>\",\n\tShort: \"Get a configuration value\",\n\tLong: `Get the value of a specific configuration key.\n\nAvailable keys:\n output.format Output format (table, json, plain)\n output.color Enable colored output (true, false)\n defaults.limit Default limit for list commands`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runConfigGet,\n}\n\nvar configSetCmd = &cobra.Command{\n\tUse: \"set \u003ckey> \u003cvalue>\",\n\tShort: \"Set a configuration value\",\n\tLong: `Set a configuration value.\n\nAvailable keys and values:\n output.format table, json, plain\n output.color true, false\n defaults.limit Any positive integer`,\n\tArgs: cobra.ExactArgs(2),\n\tRunE: runConfigSet,\n}\n\nfunc init() {\n\tconfigCmd.AddCommand(configShowCmd)\n\tconfigCmd.AddCommand(configGetCmd)\n\tconfigCmd.AddCommand(configSetCmd)\n\trootCmd.AddCommand(configCmd)\n}\n\nfunc runConfigShow(cmd *cobra.Command, args []string) error {\n\tcurrentConfig := GetConfig()\n\n\tconfigData := map[string]interface{}{\n\t\t\"output.format\": currentConfig.Output.Format,\n\t\t\"output.color\": currentConfig.Output.Color,\n\t\t\"defaults.limit\": currentConfig.Defaults.Limit,\n\t}\n\n\tconfigPath, err := config.ConfigDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get config directory: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\trenderConfigTable(configData, configPath)\n\t\t},\n\t\tconfigData,\n\t\tfunc() {\n\t\t\tprintConfigPlain(configData)\n\t\t},\n\t)\n}\n\nfunc runConfigGet(cmd *cobra.Command, args []string) error {\n\tkey := args[0]\n\n\tif _, valid := validConfigKeys[key]; !valid {\n\t\treturn fmt.Errorf(\"unknown configuration key: %s\\n\\nValid keys: %s\", key, getValidKeysList())\n\t}\n\n\tcurrentConfig := GetConfig()\n\tvalue := getConfigValue(currentConfig, key)\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\tui.RenderKeyValue([][]string{\n\t\t\t\t{key, fmt.Sprintf(\"%v\", value)},\n\t\t\t})\n\t\t},\n\t\tmap[string]interface{}{key: value},\n\t\tfunc() {\n\t\t\tui.PrintPlain(\"%v\", value)\n\t\t},\n\t)\n}\n\nfunc runConfigSet(cmd *cobra.Command, args []string) error {\n\tkey := args[0]\n\tvalue := args[1]\n\n\tkeyConfig, valid := validConfigKeys[key]\n\tif !valid {\n\t\treturn fmt.Errorf(\"unknown configuration key: %s\\n\\nValid keys: %s\", key, getValidKeysList())\n\t}\n\n\tif err := keyConfig.validate(value); err != nil {\n\t\treturn fmt.Errorf(\"invalid value for %s: %w\", key, err)\n\t}\n\n\tcurrentConfig := GetConfig()\n\tupdatedConfig := applyConfigValue(currentConfig, key, value)\n\n\tif err := config.Save(updatedConfig); err != nil {\n\t\treturn fmt.Errorf(\"failed to save configuration: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\tPrintSuccess(fmt.Sprintf(\"Set %s = %s\", key, value))\n\t\t},\n\t\tmap[string]interface{}{\n\t\t\t\"key\": key,\n\t\t\t\"value\": value,\n\t\t},\n\t\tfunc() {\n\t\t\tui.PrintPlain(\"%s=%s\", key, value)\n\t\t},\n\t)\n}\n\nfunc validateOutputFormat(value string) error {\n\tvalidFormats := []string{\"table\", \"json\", \"plain\"}\n\tfor _, format := range validFormats {\n\t\tif value == format {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn fmt.Errorf(\"must be one of: %s\", strings.Join(validFormats, \", \"))\n}\n\nfunc validateBool(value string) error {\n\tif value == \"true\" || value == \"false\" {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"must be true or false\")\n}\n\nfunc validatePositiveInt(value string) error {\n\tn, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"must be a valid number\")\n\t}\n\tif n \u003c= 0 {\n\t\treturn fmt.Errorf(\"must be a positive number\")\n\t}\n\treturn nil\n}\n\nfunc getValidKeysList() string {\n\tkeys := make([]string, 0, len(validConfigKeys))\n\tfor key := range validConfigKeys {\n\t\tkeys = append(keys, key)\n\t}\n\treturn strings.Join(keys, \", \")\n}\n\nfunc getConfigValue(cfg *config.Config, key string) interface{} {\n\tswitch key {\n\tcase \"output.format\":\n\t\treturn cfg.Output.Format\n\tcase \"output.color\":\n\t\treturn cfg.Output.Color\n\tcase \"defaults.limit\":\n\t\treturn cfg.Defaults.Limit\n\tdefault:\n\t\treturn viper.Get(key)\n\t}\n}\n\nfunc applyConfigValue(cfg *config.Config, key string, value string) *config.Config {\n\treturn &config.Config{\n\t\tAPI: cfg.API,\n\t\tOutput: applyOutputConfigValue(cfg.Output, key, value),\n\t\tDefaults: applyDefaultsConfigValue(cfg.Defaults, key, value),\n\t}\n}\n\nfunc applyOutputConfigValue(output config.OutputConfig, key string, value string) config.OutputConfig {\n\tswitch key {\n\tcase \"output.format\":\n\t\treturn config.OutputConfig{\n\t\t\tFormat: value,\n\t\t\tColor: output.Color,\n\t\t}\n\tcase \"output.color\":\n\t\treturn config.OutputConfig{\n\t\t\tFormat: output.Format,\n\t\t\tColor: value == \"true\",\n\t\t}\n\tdefault:\n\t\treturn output\n\t}\n}\n\nfunc applyDefaultsConfigValue(defaults config.DefaultsConfig, key string, value string) config.DefaultsConfig {\n\tswitch key {\n\tcase \"defaults.limit\":\n\t\tlimit, _ := strconv.Atoi(value)\n\t\treturn config.DefaultsConfig{\n\t\t\tLimit: limit,\n\t\t}\n\tdefault:\n\t\treturn defaults\n\t}\n}\n\nfunc renderConfigTable(configData map[string]interface{}, configPath string) {\n\tfmt.Printf(\"Configuration file: %s/config.yaml\\n\\n\", configPath)\n\n\trows := [][]string{\n\t\t{\"output.format\", fmt.Sprintf(\"%v\", configData[\"output.format\"]), validConfigKeys[\"output.format\"].description},\n\t\t{\"output.color\", fmt.Sprintf(\"%v\", configData[\"output.color\"]), validConfigKeys[\"output.color\"].description},\n\t\t{\"defaults.limit\", fmt.Sprintf(\"%v\", configData[\"defaults.limit\"]), validConfigKeys[\"defaults.limit\"].description},\n\t}\n\n\tui.RenderTable([]string{\"Key\", \"Value\", \"Description\"}, rows)\n}\n\nfunc printConfigPlain(configData map[string]interface{}) {\n\tui.PrintPlain(\"output.format=%v\", configData[\"output.format\"])\n\tui.PrintPlain(\"output.color=%v\", configData[\"output.color\"])\n\tui.PrintPlain(\"defaults.limit=%v\", configData[\"defaults.limit\"])\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6786,"content_sha256":"96f53ce9a0115f8c0309e44b2642b0c2dffa96a831607ada69435fce0198953f"},{"filename":"internal/cmd/events_resolve_test.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nfunc newCmdTestClient(t *testing.T, serverURL string) *api.Client {\n\tt.Helper()\n\n\tcfg := &config.Config{\n\t\tAPI: config.APIConfig{\n\t\t\tProduction: false,\n\t\t\tTimeout: 5 * time.Second,\n\t\t},\n\t}\n\n\tclient := api.NewClient(cfg, nil)\n\tclient.SetBaseURL(serverURL)\n\n\treturn client\n}\n\nfunc TestResolveSeriesTicker_ExplicitSeriesUsedDirectly(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Fatal(\"API should not be called when --series is provided explicitly\")\n\t}))\n\tdefer server.Close()\n\n\tclient := newCmdTestClient(t, server.URL)\n\tctx := context.Background()\n\n\tseries, err := resolveSeriesTicker(ctx, client, \"KXELONMARS-99\", \"KXELONMARS\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif series != \"KXELONMARS\" {\n\t\tt.Errorf(\"expected series ticker %q, got %q\", \"KXELONMARS\", series)\n\t}\n}\n\nfunc TestResolveSeriesTicker_AutoResolvesFromEvent(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\texpectedPath := api.TradeAPIPrefix + \"/events/KXELONMARS-99\"\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected path %s, got %s\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(models.EventResponse{\n\t\t\tEvent: models.Event{\n\t\t\t\tEventTicker: \"KXELONMARS-99\",\n\t\t\t\tSeriesTicker: \"KXELONMARS\",\n\t\t\t\tTitle: \"Will Elon reach Mars by 2099?\",\n\t\t\t},\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := newCmdTestClient(t, server.URL)\n\tctx := context.Background()\n\n\tseries, err := resolveSeriesTicker(ctx, client, \"KXELONMARS-99\", \"\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif series != \"KXELONMARS\" {\n\t\tt.Errorf(\"expected auto-resolved series ticker %q, got %q\", \"KXELONMARS\", series)\n\t}\n}\n\nfunc TestResolveSeriesTicker_EventNotFound(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"code\": \"not_found\",\n\t\t\t\"message\": \"event not found\",\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := newCmdTestClient(t, server.URL)\n\tctx := context.Background()\n\n\t_, err := resolveSeriesTicker(ctx, client, \"NONEXISTENT-EVENT\", \"\")\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error when event is not found, got nil\")\n\t}\n}\n\nfunc TestResolveSeriesTicker_EventHasEmptySeriesTicker(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(models.EventResponse{\n\t\t\tEvent: models.Event{\n\t\t\t\tEventTicker: \"BROKEN-EVENT\",\n\t\t\t\tSeriesTicker: \"\",\n\t\t\t\tTitle: \"Event with no series\",\n\t\t\t},\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := newCmdTestClient(t, server.URL)\n\tctx := context.Background()\n\n\t_, err := resolveSeriesTicker(ctx, client, \"BROKEN-EVENT\", \"\")\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error when event has empty series ticker, got nil\")\n\t}\n}\n\nfunc TestCandlesticksCmd_SeriesFlagIsOptional(t *testing.T) {\n\tflag := eventsCandlesticksCmd.Flags().Lookup(\"series\")\n\tif flag == nil {\n\t\tt.Fatal(\"expected --series flag to be registered\")\n\t}\n\n\t// Check that the flag is not required by looking for Cobra's required annotation\n\tannotations := flag.Annotations\n\tif annotations != nil {\n\t\tif _, ok := annotations[\"cobra_annotation_bash_completion_one_required_flag\"]; ok {\n\t\t\tt.Error(\"--series flag should NOT be marked as required\")\n\t\t}\n\t}\n}\n\nfunc TestCandlesticksCmd_SeriesFlagDescription(t *testing.T) {\n\tflag := eventsCandlesticksCmd.Flags().Lookup(\"series\")\n\tif flag == nil {\n\t\tt.Fatal(\"expected --series flag to be registered\")\n\t}\n\n\tif flag.Usage == \"series ticker (required for candlesticks)\" {\n\t\tt.Error(\"flag description still says 'required'; should indicate auto-resolution\")\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4183,"content_sha256":"a6ae698e734cde252850061364a0b1b984bbfe4cf54767f5b38eba03df03c6f7"},{"filename":"internal/cmd/events.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar eventsCmd = &cobra.Command{\n\tUse: \"events\",\n\tShort: \"Manage events\",\n\tLong: `Commands for listing, viewing, and managing Kalshi events.\n\nAn event groups related markets (e.g., \"S&P 500 close on Feb 7\" has\nmultiple price-bracket markets under it).`,\n\tExample: ` kalshi-cli events list --status active\n kalshi-cli events get INXD-25FEB07\n kalshi-cli events candlesticks INXD-25FEB07 --start 2025-02-01T00:00:00Z --end 2025-02-07T00:00:00Z`,\n}\n\nvar eventsListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List events\",\n\tLong: `List events with optional filtering by status.`,\n\tExample: ` kalshi-cli events list\n kalshi-cli events list --status active --limit 20\n kalshi-cli events list --json`,\n\tRunE: runEventsList,\n}\n\nvar eventsGetCmd = &cobra.Command{\n\tUse: \"get \u003cevent-ticker>\",\n\tShort: \"Get event details\",\n\tLong: `Get detailed information about a specific event by ticker.\n\nUse 'kalshi-cli events list' to find event tickers.`,\n\tExample: ` kalshi-cli events get INXD-25FEB07`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runEventsGet,\n}\n\nvar eventsCandlesticksCmd = &cobra.Command{\n\tUse: \"candlesticks \u003cevent-ticker>\",\n\tShort: \"Get event candlesticks\",\n\tLong: `Get candlestick (OHLCV) data for an event.\n\nThe --series flag is optional; if omitted, the series ticker is\nauto-resolved from the event. Requires --start and --end timestamps.\n\nSupported periods: 1m, 1h, 1d`,\n\tExample: ` kalshi-cli events candlesticks INXD-25FEB07 --start 2025-02-01T00:00:00Z --end 2025-02-07T00:00:00Z\n kalshi-cli events candlesticks INXD-25FEB07 --period 1d --start 2025-01-01T00:00:00Z --end 2025-02-01T00:00:00Z\n kalshi-cli events candlesticks INXD-25FEB07 --series INXD --period 1h --start 2025-02-06T00:00:00Z --end 2025-02-07T00:00:00Z`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runEventsCandlesticks,\n}\n\nvar multivariateCmd = &cobra.Command{\n\tUse: \"multivariate\",\n\tShort: \"Manage multivariate events\",\n\tLong: `Commands for listing and viewing multivariate events.`,\n}\n\nvar multivariateListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List multivariate events\",\n\tLong: `List all multivariate events.`,\n\tRunE: runMultivariateList,\n}\n\nvar multivariateGetCmd = &cobra.Command{\n\tUse: \"get \u003cticker>\",\n\tShort: \"Get multivariate event details\",\n\tLong: `Get detailed information about a specific multivariate event.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runMultivariateGet,\n}\n\nvar (\n\teventsStatus string\n\teventsLimit int\n\teventsCursor string\n\teventSeriesTicker string\n\tcandlesticksPeriod string\n\tcandlesticksStartTime string\n\tcandlesticksEndTime string\n\tmultivariateStatus string\n\tmultivariateLimit int\n\tmultivariateCursor string\n)\n\nfunc init() {\n\trootCmd.AddCommand(eventsCmd)\n\n\teventsListCmd.Flags().StringVar(&eventsStatus, \"status\", \"\", \"filter by status (active, closed, settled)\")\n\teventsListCmd.Flags().IntVar(&eventsLimit, \"limit\", 50, \"maximum number of events to return\")\n\teventsListCmd.Flags().StringVar(&eventsCursor, \"cursor\", \"\", \"pagination cursor\")\n\n\teventsCandlesticksCmd.Flags().StringVar(&eventSeriesTicker, \"series\", \"\", \"series ticker (auto-resolved from event if not provided)\")\n\teventsCandlesticksCmd.Flags().StringVar(&candlesticksPeriod, \"period\", \"1h\", \"candlestick period (1m, 1h, 1d)\")\n\teventsCandlesticksCmd.Flags().StringVar(&candlesticksStartTime, \"start\", \"\", \"start time (RFC3339 format)\")\n\teventsCandlesticksCmd.Flags().StringVar(&candlesticksEndTime, \"end\", \"\", \"end time (RFC3339 format)\")\n\n\tmultivariateListCmd.Flags().StringVar(&multivariateStatus, \"status\", \"\", \"filter by status\")\n\tmultivariateListCmd.Flags().IntVar(&multivariateLimit, \"limit\", 50, \"maximum number of events to return\")\n\tmultivariateListCmd.Flags().StringVar(&multivariateCursor, \"cursor\", \"\", \"pagination cursor\")\n\n\tmultivariateCmd.AddCommand(multivariateListCmd)\n\tmultivariateCmd.AddCommand(multivariateGetCmd)\n\n\teventsCmd.AddCommand(eventsListCmd)\n\teventsCmd.AddCommand(eventsGetCmd)\n\teventsCmd.AddCommand(eventsCandlesticksCmd)\n\teventsCmd.AddCommand(multivariateCmd)\n}\n\nfunc runEventsList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tparams := api.ListEventsParams{\n\t\tStatus: eventsStatus,\n\t\tLimit: eventsLimit,\n\t\tCursor: eventsCursor,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tevents, cursor, err := client.ListEvents(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list events: %w\", err)\n\t}\n\n\toutputFormat := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFormat,\n\t\tfunc() { renderEventsTable(events, cursor) },\n\t\tcreateEventsResponse(events, cursor),\n\t\tfunc() { renderEventsPlain(events) },\n\t)\n}\n\nfunc runEventsGet(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tevent, err := client.GetEvent(ctx, ticker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get event: %w\", err)\n\t}\n\n\toutputFormat := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFormat,\n\t\tfunc() { renderEventDetails(event) },\n\t\tevent,\n\t\tfunc() { renderEventPlain(event) },\n\t)\n}\n\nfunc runEventsCandlesticks(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tseriesTicker, err := resolveSeriesTicker(ctx, client, ticker, eventSeriesTicker)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tparams := api.CandlesticksParams{\n\t\tTicker: ticker,\n\t\tSeriesTicker: seriesTicker,\n\t\tPeriod: candlesticksPeriod,\n\t}\n\n\tif candlesticksStartTime != \"\" {\n\t\tt, parseErr := time.Parse(time.RFC3339, candlesticksStartTime)\n\t\tif parseErr != nil {\n\t\t\treturn fmt.Errorf(\"invalid start time format: %w\", parseErr)\n\t\t}\n\t\tparams.StartTime = &t\n\t}\n\n\tif candlesticksEndTime != \"\" {\n\t\tt, parseErr := time.Parse(time.RFC3339, candlesticksEndTime)\n\t\tif parseErr != nil {\n\t\t\treturn fmt.Errorf(\"invalid end time format: %w\", parseErr)\n\t\t}\n\t\tparams.EndTime = &t\n\t}\n\n\tcandlesticks, err := client.GetEventCandlesticks(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get candlesticks: %w\", err)\n\t}\n\n\toutputFormat := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFormat,\n\t\tfunc() { renderCandlesticksTable(candlesticks) },\n\t\tcandlesticks,\n\t\tfunc() { renderCandlesticksPlain(candlesticks) },\n\t)\n}\n\n// resolveSeriesTicker returns the series ticker for the candlesticks API call.\n// If explicitSeries is provided (via --series flag), it is returned directly.\n// Otherwise, the event is fetched to extract its SeriesTicker field.\nfunc resolveSeriesTicker(ctx context.Context, client *api.Client, ticker string, explicitSeries string) (string, error) {\n\tif explicitSeries != \"\" {\n\t\treturn explicitSeries, nil\n\t}\n\n\tevent, err := client.GetEvent(ctx, ticker)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve series ticker for event %s: %w\", ticker, err)\n\t}\n\n\tif event.SeriesTicker == \"\" {\n\t\treturn \"\", fmt.Errorf(\"event %s has no series ticker; please provide --series explicitly\", ticker)\n\t}\n\n\treturn event.SeriesTicker, nil\n}\n\nfunc runMultivariateList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tparams := api.ListMultivariateParams{\n\t\tStatus: multivariateStatus,\n\t\tLimit: multivariateLimit,\n\t\tCursor: multivariateCursor,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tevents, cursor, err := client.ListMultivariateEvents(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list multivariate events: %w\", err)\n\t}\n\n\toutputFormat := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFormat,\n\t\tfunc() { renderMultivariateEventsTable(events, cursor) },\n\t\tcreateMultivariateResponse(events, cursor),\n\t\tfunc() { renderMultivariateEventsPlain(events) },\n\t)\n}\n\nfunc runMultivariateGet(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tevent, err := client.GetMultivariateEvent(ctx, ticker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get multivariate event: %w\", err)\n\t}\n\n\toutputFormat := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFormat,\n\t\tfunc() { renderMultivariateEventDetails(event) },\n\t\tevent,\n\t\tfunc() { renderMultivariateEventPlain(event) },\n\t)\n}\n\n// Table Rendering\n\nfunc renderEventsTable(events []models.Event, cursor string) {\n\theaders := []string{\"Ticker\", \"Title\", \"Category\", \"Markets\"}\n\trows := make([][]string, 0, len(events))\n\n\tfor _, e := range events {\n\t\trows = append(rows, []string{\n\t\t\te.EventTicker,\n\t\t\ttruncateEventString(e.Title, 40),\n\t\t\te.Category,\n\t\t\tstrconv.Itoa(len(e.Markets)),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n\n\tif cursor != \"\" {\n\t\tfmt.Printf(\"\\nMore results available. Use --cursor %s to continue.\\n\", cursor)\n\t}\n}\n\nfunc renderEventDetails(event *models.Event) {\n\tpairs := [][]string{\n\t\t{\"Ticker\", event.EventTicker},\n\t\t{\"Series\", event.SeriesTicker},\n\t\t{\"Title\", event.Title},\n\t\t{\"Subtitle\", event.SubTitle},\n\t\t{\"Category\", event.Category},\n\t\t{\"Mutually Exclusive\", formatEventBool(event.MutuallyExclusive)},\n\t\t{\"Strike Date\", formatEventTimePtr(event.StrikeDate)},\n\t\t{\"Markets Count\", strconv.Itoa(len(event.Markets))},\n\t}\n\n\tui.RenderKeyValue(pairs)\n\n\tif len(event.Markets) > 0 {\n\t\tfmt.Println(\"\\nMarkets:\")\n\t\tfor _, m := range event.Markets {\n\t\t\tfmt.Printf(\" - %s\\n\", m)\n\t\t}\n\t}\n}\n\nfunc renderCandlesticksTable(candlesticks []models.Candlestick) {\n\tui.RenderCandlestickChart(eventCandlesToChartData(candlesticks), \"Event Candlesticks\")\n\n\theaders := []string{\"Time\", \"Open\", \"High\", \"Low\", \"Close\", \"Volume\", \"OI\"}\n\trows := make([][]string, 0, len(candlesticks))\n\n\tfor _, c := range candlesticks {\n\t\trows = append(rows, []string{\n\t\t\tc.PeriodEnd.Format(\"2006-01-02 15:04\"),\n\t\t\tui.FormatPrice(c.Open),\n\t\t\tui.FormatPrice(c.High),\n\t\t\tui.FormatPrice(c.Low),\n\t\t\tui.FormatPrice(c.Close),\n\t\t\tstrconv.Itoa(c.Volume),\n\t\t\tstrconv.Itoa(c.OpenInterest),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc eventCandlesToChartData(candles []models.Candlestick) []ui.CandleData {\n\tdata := make([]ui.CandleData, len(candles))\n\tfor i, c := range candles {\n\t\tdata[i] = ui.CandleData{\n\t\t\tLabel: c.PeriodEnd.Format(\"01/02 15:04\"),\n\t\t\tOpen: c.Open,\n\t\t\tHigh: c.High,\n\t\t\tLow: c.Low,\n\t\t\tClose: c.Close,\n\t\t\tVolume: c.Volume,\n\t\t}\n\t}\n\treturn data\n}\n\nfunc renderMultivariateEventsTable(events []models.MultivariateEvent, cursor string) {\n\theaders := []string{\"Ticker\", \"Title\", \"Status\", \"Lookup Type\"}\n\trows := make([][]string, 0, len(events))\n\n\tfor _, e := range events {\n\t\trows = append(rows, []string{\n\t\t\te.Ticker,\n\t\t\ttruncateEventString(e.Title, 40),\n\t\t\tformatEventStatus(e.Status),\n\t\t\te.LookupType,\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n\n\tif cursor != \"\" {\n\t\tfmt.Printf(\"\\nMore results available. Use --cursor %s to continue.\\n\", cursor)\n\t}\n}\n\nfunc renderMultivariateEventDetails(event *models.MultivariateEvent) {\n\tpairs := [][]string{\n\t\t{\"Ticker\", event.Ticker},\n\t\t{\"Title\", event.Title},\n\t\t{\"Description\", event.Description},\n\t\t{\"Status\", formatEventStatus(event.Status)},\n\t\t{\"Lookup Type\", event.LookupType},\n\t}\n\n\tui.RenderKeyValue(pairs)\n\n\tif len(event.LookupTable) > 0 {\n\t\tfmt.Println(\"\\nLookup Table:\")\n\t\tfor i, item := range event.LookupTable {\n\t\t\tfmt.Printf(\" %d. %s\\n\", i+1, item)\n\t\t}\n\t}\n}\n\n// Plain Rendering\n\nfunc renderEventsPlain(events []models.Event) {\n\tfor _, e := range events {\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%d\\n\",\n\t\t\te.EventTicker, e.Title, e.Category, len(e.Markets))\n\t}\n}\n\nfunc renderEventPlain(event *models.Event) {\n\tfmt.Printf(\"ticker=%s\\n\", event.EventTicker)\n\tfmt.Printf(\"series=%s\\n\", event.SeriesTicker)\n\tfmt.Printf(\"title=%s\\n\", event.Title)\n\tfmt.Printf(\"category=%s\\n\", event.Category)\n\tfmt.Printf(\"markets_count=%d\\n\", len(event.Markets))\n\tif len(event.Markets) > 0 {\n\t\tfmt.Printf(\"markets=%s\\n\", strings.Join(event.Markets, \",\"))\n\t}\n}\n\nfunc renderCandlesticksPlain(candlesticks []models.Candlestick) {\n\tfor _, c := range candlesticks {\n\t\tfmt.Printf(\"%s\\t%d\\t%d\\t%d\\t%d\\t%d\\t%d\\n\",\n\t\t\tc.PeriodEnd.Format(time.RFC3339),\n\t\t\tc.Open, c.High, c.Low, c.Close, c.Volume, c.OpenInterest)\n\t}\n}\n\nfunc renderMultivariateEventsPlain(events []models.MultivariateEvent) {\n\tfor _, e := range events {\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\n\", e.Ticker, e.Title, e.Status, e.LookupType)\n\t}\n}\n\nfunc renderMultivariateEventPlain(event *models.MultivariateEvent) {\n\tfmt.Printf(\"ticker=%s\\n\", event.Ticker)\n\tfmt.Printf(\"title=%s\\n\", event.Title)\n\tfmt.Printf(\"description=%s\\n\", event.Description)\n\tfmt.Printf(\"status=%s\\n\", event.Status)\n\tfmt.Printf(\"lookup_type=%s\\n\", event.LookupType)\n}\n\n// Response Helpers\n\ntype eventsListResponse struct {\n\tEvents []models.Event `json:\"events\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\ntype multivariateListResponse struct {\n\tEvents []models.MultivariateEvent `json:\"multivariate_events\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\nfunc createEventsResponse(events []models.Event, cursor string) eventsListResponse {\n\treturn eventsListResponse{\n\t\tEvents: events,\n\t\tCursor: cursor,\n\t}\n}\n\nfunc createMultivariateResponse(events []models.MultivariateEvent, cursor string) multivariateListResponse {\n\treturn multivariateListResponse{\n\t\tEvents: events,\n\t\tCursor: cursor,\n\t}\n}\n\n// Formatting Helpers (prefixed with \"Event\" to avoid conflicts with other cmd files)\n\nfunc truncateEventString(s string, maxLen int) string {\n\tif len(s) \u003c= maxLen {\n\t\treturn s\n\t}\n\treturn s[:maxLen-3] + \"...\"\n}\n\nfunc formatEventStatus(status string) string {\n\treturn strings.ToUpper(status)\n}\n\nfunc formatEventBool(b bool) string {\n\tif b {\n\t\treturn \"Yes\"\n\t}\n\treturn \"No\"\n}\n\nfunc formatEventTime(t time.Time) string {\n\tif t.IsZero() {\n\t\treturn \"-\"\n\t}\n\treturn t.Format(\"2006-01-02 15:04:05\")\n}\n\nfunc formatEventTimePtr(t *time.Time) string {\n\tif t == nil {\n\t\treturn \"-\"\n\t}\n\treturn formatEventTime(*t)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":14290,"content_sha256":"06116429a4de569e0351e8044dbafe3bb956a79b81773fc35409980955a604d1"},{"filename":"internal/cmd/exchange.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar exchangeCmd = &cobra.Command{\n\tUse: \"exchange\",\n\tShort: \"Exchange information and status\",\n\tLong: `Get exchange status, schedule, and announcements.`,\n}\n\nvar exchangeStatusCmd = &cobra.Command{\n\tUse: \"status\",\n\tShort: \"Get exchange status\",\n\tLong: `Get the current exchange status including trading activity and environment.`,\n\tRunE: runExchangeStatus,\n}\n\nvar exchangeScheduleCmd = &cobra.Command{\n\tUse: \"schedule\",\n\tShort: \"Get exchange schedule\",\n\tLong: `Get the exchange trading schedule.`,\n\tRunE: runExchangeSchedule,\n}\n\nvar exchangeAnnouncementsCmd = &cobra.Command{\n\tUse: \"announcements\",\n\tShort: \"Get exchange announcements\",\n\tLong: `Get the latest exchange announcements.`,\n\tRunE: runExchangeAnnouncements,\n}\n\nfunc init() {\n\trootCmd.AddCommand(exchangeCmd)\n\texchangeCmd.AddCommand(exchangeStatusCmd)\n\texchangeCmd.AddCommand(exchangeScheduleCmd)\n\texchangeCmd.AddCommand(exchangeAnnouncementsCmd)\n}\n\n// createAPIClient is defined in helpers.go\n\nfunc runExchangeStatus(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tstatus, err := client.GetExchangeStatus(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get exchange status: %w\", err)\n\t}\n\n\tcfg := GetConfig()\n\toutputFmt := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderExchangeStatusTable(status, cfg) },\n\t\tstatus,\n\t\tfunc() { renderExchangeStatusPlain(status, cfg) },\n\t)\n}\n\nfunc renderExchangeStatusTable(status *api.ExchangeStatusResponse, cfg *config.Config) {\n\texchangeActive := formatStatusBool(status.ExchangeActive)\n\ttradingActive := formatStatusBool(status.TradingActive)\n\tenvironment := formatEnvironment(cfg.API.Production)\n\n\tpairs := [][]string{\n\t\t{ui.BoldStyle.Render(\"Exchange Active:\"), exchangeActive},\n\t\t{ui.BoldStyle.Render(\"Trading Active:\"), tradingActive},\n\t\t{ui.BoldStyle.Render(\"Environment:\"), environment},\n\t}\n\n\tui.RenderKeyValue(pairs)\n}\n\nfunc renderExchangeStatusPlain(status *api.ExchangeStatusResponse, cfg *config.Config) {\n\texchangeActive := boolToYesNo(status.ExchangeActive)\n\ttradingActive := boolToYesNo(status.TradingActive)\n\tenvironment := cfg.Environment()\n\n\tfmt.Printf(\"exchange_active=%s\\n\", exchangeActive)\n\tfmt.Printf(\"trading_active=%s\\n\", tradingActive)\n\tfmt.Printf(\"environment=%s\\n\", environment)\n}\n\nfunc formatStatusBool(active bool) string {\n\tif active {\n\t\treturn ui.SuccessStyle.Render(\"Yes\")\n\t}\n\treturn ui.ErrorStyle.Render(\"No\")\n}\n\nfunc formatEnvironment(isProd bool) string {\n\tif isProd {\n\t\treturn ui.ProdStyle.Render(\"Production\")\n\t}\n\treturn ui.DemoStyle.Render(\"Demo\")\n}\n\nfunc boolToYesNo(b bool) string {\n\tif b {\n\t\treturn \"yes\"\n\t}\n\treturn \"no\"\n}\n\nfunc runExchangeSchedule(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tschedule, err := client.GetExchangeSchedule(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get exchange schedule: %w\", err)\n\t}\n\n\toutputFmt := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderScheduleTable(schedule) },\n\t\tschedule,\n\t\tfunc() { renderSchedulePlain(schedule) },\n\t)\n}\n\nfunc renderScheduleTable(schedule *models.ExchangeScheduleResponse) {\n\tif len(schedule.Schedule.StandardHours) > 0 {\n\t\tfmt.Println(ui.HeaderStyle.Render(\"Standard Hours\"))\n\t\tfor _, week := range schedule.Schedule.StandardHours {\n\t\t\tfmt.Printf(\" Period: %s to %s\\n\", week.StartTime, week.EndTime)\n\t\t\tshowDay(\"Monday\", week.Monday)\n\t\t\tshowDay(\"Tuesday\", week.Tuesday)\n\t\t\tshowDay(\"Wednesday\", week.Wednesday)\n\t\t\tshowDay(\"Thursday\", week.Thursday)\n\t\t\tshowDay(\"Friday\", week.Friday)\n\t\t\tshowDay(\"Saturday\", week.Saturday)\n\t\t\tshowDay(\"Sunday\", week.Sunday)\n\t\t}\n\t}\n\n\tif len(schedule.Schedule.MaintenanceWindows) > 0 {\n\t\tfmt.Println(ui.HeaderStyle.Render(\"Maintenance Windows\"))\n\t\tfor _, mw := range schedule.Schedule.MaintenanceWindows {\n\t\t\tfmt.Printf(\" %s to %s\\n\", mw.StartDatetime, mw.EndDatetime)\n\t\t}\n\t}\n\n\tif len(schedule.Schedule.StandardHours) == 0 && len(schedule.Schedule.MaintenanceWindows) == 0 {\n\t\tfmt.Println(ui.MutedStyle.Render(\"No schedule entries found.\"))\n\t}\n}\n\nfunc showDay(name string, slots []models.DailySchedule) {\n\tif len(slots) == 0 {\n\t\treturn\n\t}\n\tfor _, s := range slots {\n\t\tfmt.Printf(\" %s: %s - %s\\n\", name, s.OpenTime, s.CloseTime)\n\t}\n}\n\nfunc renderSchedulePlain(schedule *models.ExchangeScheduleResponse) {\n\tfor i, week := range schedule.Schedule.StandardHours {\n\t\tfmt.Printf(\"week_%d_start=%s\\n\", i, week.StartTime)\n\t\tfmt.Printf(\"week_%d_end=%s\\n\", i, week.EndTime)\n\t\tprintDayPlain(i, \"monday\", week.Monday)\n\t\tprintDayPlain(i, \"tuesday\", week.Tuesday)\n\t\tprintDayPlain(i, \"wednesday\", week.Wednesday)\n\t\tprintDayPlain(i, \"thursday\", week.Thursday)\n\t\tprintDayPlain(i, \"friday\", week.Friday)\n\t\tprintDayPlain(i, \"saturday\", week.Saturday)\n\t\tprintDayPlain(i, \"sunday\", week.Sunday)\n\t}\n\tfor i, mw := range schedule.Schedule.MaintenanceWindows {\n\t\tfmt.Printf(\"maintenance_%d_start=%s\\n\", i, mw.StartDatetime)\n\t\tfmt.Printf(\"maintenance_%d_end=%s\\n\", i, mw.EndDatetime)\n\t}\n}\n\nfunc printDayPlain(weekIdx int, day string, slots []models.DailySchedule) {\n\tfor j, s := range slots {\n\t\tfmt.Printf(\"week_%d_%s_%d_open=%s\\n\", weekIdx, day, j, s.OpenTime)\n\t\tfmt.Printf(\"week_%d_%s_%d_close=%s\\n\", weekIdx, day, j, s.CloseTime)\n\t}\n}\n\nfunc formatTime(t time.Time) string {\n\tif t.IsZero() {\n\t\treturn \"-\"\n\t}\n\treturn t.Local().Format(\"2006-01-02 15:04 MST\")\n}\n\nfunc runExchangeAnnouncements(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tannouncements, err := client.GetAnnouncements(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get announcements: %w\", err)\n\t}\n\n\toutputFmt := GetOutputFormat()\n\n\treturn ui.Output(\n\t\toutputFmt,\n\t\tfunc() { renderAnnouncementsTable(announcements) },\n\t\tannouncements,\n\t\tfunc() { renderAnnouncementsPlain(announcements) },\n\t)\n}\n\nfunc renderAnnouncementsTable(announcements *models.AnnouncementsResponse) {\n\tif len(announcements.Announcements) == 0 {\n\t\tfmt.Println(ui.MutedStyle.Render(\"No announcements found.\"))\n\t\treturn\n\t}\n\n\theaders := []string{\"Title\", \"Type\", \"Status\", \"Delivery Time\"}\n\tvar rows [][]string\n\n\tfor _, ann := range announcements.Announcements {\n\t\tstatus := formatAnnouncementStatus(ann.Status)\n\n\t\trows = append(rows, []string{\n\t\t\ttruncateString(ann.Title, 50),\n\t\t\tann.Type,\n\t\t\tstatus,\n\t\t\tformatTime(ann.DeliveryTime),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderAnnouncementsPlain(announcements *models.AnnouncementsResponse) {\n\tfor i, ann := range announcements.Announcements {\n\t\tfmt.Printf(\"announcement_%d_id=%s\\n\", i, ann.ID)\n\t\tfmt.Printf(\"announcement_%d_title=%s\\n\", i, ann.Title)\n\t\tfmt.Printf(\"announcement_%d_type=%s\\n\", i, ann.Type)\n\t\tfmt.Printf(\"announcement_%d_status=%s\\n\", i, ann.Status)\n\t\tfmt.Printf(\"announcement_%d_delivery_time=%s\\n\", i, ann.DeliveryTime.Format(time.RFC3339))\n\t}\n}\n\nfunc formatAnnouncementStatus(status string) string {\n\tswitch status {\n\tcase \"active\":\n\t\treturn ui.SuccessStyle.Render(status)\n\tcase \"pending\":\n\t\treturn ui.WarningStyle.Render(status)\n\tcase \"expired\":\n\t\treturn ui.MutedStyle.Render(status)\n\tdefault:\n\t\treturn status\n\t}\n}\n\nfunc truncateString(s string, maxLen int) string {\n\trunes := []rune(s)\n\tif len(runes) \u003c= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[:maxLen-3]) + \"...\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7815,"content_sha256":"9c3c70e7cf71bff6cf09de7fa7bdfe833e8b96d1c38dff6475aec1d67b82c696"},{"filename":"internal/cmd/helpers.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/spf13/viper\"\n)\n\n// Common helper functions shared across commands\n\n// createClient creates an API client using stored credentials.\n// It tries config file credentials first (api_key_id + private_key_path),\n// then environment variables, then the keyring as a last resort.\n// Config/env is checked first because keyring can hang in headless environments.\nfunc createClient() (*api.Client, error) {\n\t// Try config file first (fast, no GUI prompts)\n\tapiKeyID := viper.GetString(\"api_key_id\")\n\tprivateKeyPath := viper.GetString(\"private_key_path\")\n\n\t// Also check env vars\n\tif apiKeyID == \"\" {\n\t\tapiKeyID = os.Getenv(\"KALSHI_API_KEY_ID\")\n\t}\n\tif privateKeyPath == \"\" {\n\t\tprivateKeyPath = os.Getenv(\"KALSHI_PRIVATE_KEY_FILE\")\n\t}\n\n\tif apiKeyID != \"\" && privateKeyPath != \"\" {\n\t\tpemData, err := os.ReadFile(privateKeyPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read private key file %s: %w\", privateKeyPath, err)\n\t\t}\n\n\t\tsigner, err := api.NewSignerFromPEM(apiKeyID, string(pemData))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create signer from key file: %w\", err)\n\t\t}\n\n\t\treturn api.NewClient(cfg, signer), nil\n\t}\n\n\t// Also support KALSHI_PRIVATE_KEY env var (PEM content directly)\n\tprivateKeyPEM := os.Getenv(\"KALSHI_PRIVATE_KEY\")\n\tif apiKeyID != \"\" && privateKeyPEM != \"\" {\n\t\tsigner, err := api.NewSignerFromPEM(apiKeyID, privateKeyPEM)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create signer from env var: %w\", err)\n\t\t}\n\n\t\treturn api.NewClient(cfg, signer), nil\n\t}\n\n\t// Last resort: try keyring (may hang in headless environments)\n\tkeyring, err := config.NewKeyringStore()\n\tif err == nil {\n\t\tcreds, err := keyring.GetCredentials()\n\t\tif err == nil && creds != nil {\n\t\t\tsigner, err := api.NewSignerFromPEM(creds.APIKeyID, creds.PrivateKey)\n\t\t\tif err == nil {\n\t\t\t\treturn api.NewClient(cfg, signer), nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"not logged in. Set api_key_id + private_key_path in ~/.kalshi/config.yaml, or run 'kalshi-cli auth login'\")\n}\n\n// formatTimeStr formats a time.Time for display\nfunc formatTimeStr(t time.Time) string {\n\tif t.IsZero() {\n\t\treturn \"-\"\n\t}\n\treturn t.Format(\"2006-01-02 15:04\")\n}\n\n// truncateStr truncates a string to max length with ellipsis\nfunc truncateStr(s string, max int) string {\n\tif len(s) \u003c= max {\n\t\treturn s\n\t}\n\tif max \u003c= 3 {\n\t\treturn s[:max]\n\t}\n\treturn s[:max-3] + \"...\"\n}\n\n// formatMarketStatus formats a market status for display\nfunc formatMarketStatus(status string) string {\n\tswitch status {\n\tcase \"open\":\n\t\treturn \"Open\"\n\tcase \"closed\":\n\t\treturn \"Closed\"\n\tcase \"settled\":\n\t\treturn \"Settled\"\n\tdefault:\n\t\treturn status\n\t}\n}\n\n// formatSideStr formats a side (yes/no) for display\nfunc formatSideStr(side string) string {\n\tswitch side {\n\tcase \"yes\":\n\t\treturn \"Yes\"\n\tcase \"no\":\n\t\treturn \"No\"\n\tdefault:\n\t\treturn side\n\t}\n}\n\n// formatCents formats cents as dollars\nfunc formatCents(cents int) string {\n\treturn fmt.Sprintf(\"$%.2f\", float64(cents)/100)\n}\n\n// confirmAction prompts for confirmation unless --yes flag is set\nfunc confirmAction(action string) bool {\n\tif yesFlag {\n\t\treturn true\n\t}\n\n\tfmt.Printf(\"Are you sure you want to %s? [y/N]: \", action)\n\tvar response string\n\tfmt.Scanln(&response)\n\treturn response == \"y\" || response == \"Y\" || response == \"yes\" || response == \"Yes\"\n}\n\n// withTimeout returns a context with the configured timeout\nfunc withTimeout(parent context.Context) (context.Context, context.CancelFunc) {\n\ttimeout := 30 * time.Second\n\tif cfg != nil && cfg.API.Timeout > 0 {\n\t\ttimeout = cfg.API.Timeout\n\t}\n\treturn context.WithTimeout(parent, timeout)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3725,"content_sha256":"371e3c1d8eda6f4145f8dd37eede6b2c74ba42175b185efdcb64050c9499e25a"},{"filename":"internal/cmd/markets.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar marketsCmd = &cobra.Command{\n\tUse: \"markets\",\n\tShort: \"Manage and view markets\",\n\tLong: `Commands for listing, viewing, and analyzing prediction markets.`,\n\tExample: ` kalshi-cli markets list --status open\n kalshi-cli markets get INXD-25FEB07-B5523.99\n kalshi-cli markets orderbook INXD-25FEB07-B5523.99\n kalshi-cli markets trades INXD-25FEB07-B5523.99`,\n}\n\nvar marketsListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List markets\",\n\tLong: `List markets with optional filtering by status and series.`,\n\tExample: ` kalshi-cli markets list\n kalshi-cli markets list --status open --limit 20\n kalshi-cli markets list --series INXD --json`,\n\tRunE: runMarketsList,\n}\n\nvar marketsGetCmd = &cobra.Command{\n\tUse: \"get \u003cmarket-ticker>\",\n\tShort: \"Get market details\",\n\tLong: `Get detailed information about a specific market.\n\nUse 'kalshi-cli markets list' to find market tickers.`,\n\tExample: ` kalshi-cli markets get INXD-25FEB07-B5523.99`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runMarketsGet,\n}\n\nvar marketsOrderbookCmd = &cobra.Command{\n\tUse: \"orderbook \u003cmarket-ticker>\",\n\tShort: \"Get market orderbook\",\n\tLong: `Get the orderbook for a specific market with visual display.\n\nShows YES bids and asks with quantities at each price level.`,\n\tExample: ` kalshi-cli markets orderbook INXD-25FEB07-B5523.99\n kalshi-cli markets orderbook INXD-25FEB07-B5523.99 --json`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runMarketsOrderbook,\n}\n\nvar marketsTradesCmd = &cobra.Command{\n\tUse: \"trades \u003cmarket-ticker>\",\n\tShort: \"Get market trades\",\n\tLong: `Get recent trades for a specific market.`,\n\tExample: ` kalshi-cli markets trades INXD-25FEB07-B5523.99\n kalshi-cli markets trades INXD-25FEB07-B5523.99 --limit 20`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runMarketsTrades,\n}\n\nvar marketsCandlesticksCmd = &cobra.Command{\n\tUse: \"candlesticks \u003cmarket-ticker>\",\n\tShort: \"Get market candlesticks\",\n\tLong: `Get candlestick (OHLCV) data for a specific market.\n\nRequires --series flag with the series ticker.\nSupported periods: 1m, 1h, 1d`,\n\tExample: ` kalshi-cli markets candlesticks INXD-25FEB07-B5523.99 --series INXD\n kalshi-cli markets candlesticks INXD-25FEB07-B5523.99 --series INXD --period 1d`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runMarketsCandlesticks,\n}\n\nvar seriesCmd = &cobra.Command{\n\tUse: \"series\",\n\tShort: \"Manage and view series\",\n\tLong: `Commands for listing and viewing market series.`,\n}\n\nvar seriesListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List series\",\n\tLong: `List market series with optional category filtering.`,\n\tExample: ` kalshi-cli markets series list\n kalshi-cli markets series list --category economics`,\n\tRunE: runSeriesList,\n}\n\nvar seriesGetCmd = &cobra.Command{\n\tUse: \"get \u003cseries-ticker>\",\n\tShort: \"Get series details\",\n\tLong: `Get detailed information about a specific series.`,\n\tExample: ` kalshi-cli markets series get INXD`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runSeriesGet,\n}\n\n// Command flags\nvar (\n\tmarketStatus string\n\tmarketLimit int\n\tseriesTicker string\n\ttradesLimit int\n\tcandlePeriod string\n\tcandleSeriesTicker string\n\tseriesCategory string\n\tseriesLimit int\n)\n\nfunc init() {\n\tmarketsListCmd.Flags().StringVar(&marketStatus, \"status\", \"\", \"filter by status (open, closed, settled)\")\n\tmarketsListCmd.Flags().IntVar(&marketLimit, \"limit\", 50, \"maximum number of markets to return\")\n\tmarketsListCmd.Flags().StringVar(&seriesTicker, \"series\", \"\", \"filter by series ticker\")\n\n\tmarketsTradesCmd.Flags().IntVar(&tradesLimit, \"limit\", 100, \"maximum number of trades to return\")\n\n\tmarketsCandlesticksCmd.Flags().StringVar(&candlePeriod, \"period\", \"1h\", \"candlestick period (1m, 1h, 1d)\")\n\tmarketsCandlesticksCmd.Flags().StringVar(&candleSeriesTicker, \"series\", \"\", \"series ticker (required for candlesticks)\")\n\tmarketsCandlesticksCmd.MarkFlagRequired(\"series\")\n\n\tseriesListCmd.Flags().StringVar(&seriesCategory, \"category\", \"\", \"filter by category\")\n\tseriesListCmd.Flags().IntVar(&seriesLimit, \"limit\", 50, \"maximum number of series to return\")\n\n\tseriesCmd.AddCommand(seriesListCmd)\n\tseriesCmd.AddCommand(seriesGetCmd)\n\n\tmarketsCmd.AddCommand(marketsListCmd)\n\tmarketsCmd.AddCommand(marketsGetCmd)\n\tmarketsCmd.AddCommand(marketsOrderbookCmd)\n\tmarketsCmd.AddCommand(marketsTradesCmd)\n\tmarketsCmd.AddCommand(marketsCandlesticksCmd)\n\tmarketsCmd.AddCommand(seriesCmd)\n\n\trootCmd.AddCommand(marketsCmd)\n}\n\nfunc runMarketsList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tparams := api.ListMarketsParams{\n\t\tStatus: marketStatus,\n\t\tSeriesTicker: seriesTicker,\n\t\tLimit: marketLimit,\n\t}\n\n\tresult, err := client.ListMarkets(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list markets: %w\", err)\n\t}\n\n\treturn outputMarketsList(result.Markets)\n}\n\nfunc outputMarketsList(markets []models.Market) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\theaders := []string{\"Ticker\", \"Title\", \"Status\", \"Yes Bid\", \"Yes Ask\", \"Volume\"}\n\t\tvar rows [][]string\n\n\t\tfor _, m := range markets {\n\t\t\ttitle := truncateMarketString(m.Title, 50)\n\t\t\trows = append(rows, []string{\n\t\t\t\tm.Ticker,\n\t\t\t\ttitle,\n\t\t\t\tformatMarketStatus(m.Status),\n\t\t\t\tformatCents(m.YesBid),\n\t\t\t\tformatCents(m.YesAsk),\n\t\t\t\tfmt.Sprintf(\"%d\", m.Volume),\n\t\t\t})\n\t\t}\n\n\t\tui.RenderTable(headers, rows)\n\t}\n\n\tplainFunc := func() {\n\t\tfor _, m := range markets {\n\t\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\t%s\\t%d\\n\",\n\t\t\t\tm.Ticker,\n\t\t\t\tm.Title,\n\t\t\t\tm.Status,\n\t\t\t\tformatCents(m.YesBid),\n\t\t\t\tformatCents(m.YesAsk),\n\t\t\t\tm.Volume,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, markets, plainFunc)\n}\n\nfunc runMarketsGet(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tmarket, err := client.GetMarket(ctx, ticker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get market: %w\", err)\n\t}\n\n\treturn outputMarketDetails(market)\n}\n\nfunc outputMarketDetails(market *models.Market) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\tpairs := [][]string{\n\t\t\t{\"Ticker\", market.Ticker},\n\t\t\t{\"Title\", market.Title},\n\t\t\t{\"Subtitle\", market.Subtitle},\n\t\t\t{\"Status\", formatMarketStatus(market.Status)},\n\t\t\t{\"Category\", market.Category},\n\t\t\t{\"Yes Bid\", formatCents(market.YesBid)},\n\t\t\t{\"Yes Ask\", formatCents(market.YesAsk)},\n\t\t\t{\"No Bid\", formatCents(market.NoBid)},\n\t\t\t{\"No Ask\", formatCents(market.NoAsk)},\n\t\t\t{\"Last Price\", formatCents(market.LastPrice)},\n\t\t\t{\"Volume\", fmt.Sprintf(\"%d\", market.Volume)},\n\t\t\t{\"Volume 24h\", fmt.Sprintf(\"%d\", market.Volume24H)},\n\t\t\t{\"Open Interest\", fmt.Sprintf(\"%d\", market.OpenInterest)},\n\t\t\t{\"Open Time\", formatMarketTime(market.OpenTime)},\n\t\t\t{\"Close Time\", formatMarketTime(market.CloseTime)},\n\t\t\t{\"Expiration\", formatMarketTime(market.ExpirationTime)},\n\t\t}\n\n\t\tif market.Result != \"\" {\n\t\t\tpairs = append(pairs, []string{\"Result\", market.Result})\n\t\t}\n\n\t\tui.RenderKeyValue(pairs)\n\t}\n\n\tplainFunc := func() {\n\t\tfmt.Printf(\"Ticker: %s\\n\", market.Ticker)\n\t\tfmt.Printf(\"Title: %s\\n\", market.Title)\n\t\tfmt.Printf(\"Status: %s\\n\", market.Status)\n\t\tfmt.Printf(\"Yes Bid/Ask: %s / %s\\n\", formatCents(market.YesBid), formatCents(market.YesAsk))\n\t\tfmt.Printf(\"No Bid/Ask: %s / %s\\n\", formatCents(market.NoBid), formatCents(market.NoAsk))\n\t\tfmt.Printf(\"Last Price: %s\\n\", formatCents(market.LastPrice))\n\t\tfmt.Printf(\"Volume: %d\\n\", market.Volume)\n\t}\n\n\treturn ui.Output(format, tableFunc, market, plainFunc)\n}\n\nfunc runMarketsOrderbook(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\torderbook, err := client.GetOrderbook(ctx, ticker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get orderbook: %w\", err)\n\t}\n\n\treturn outputOrderbook(orderbook)\n}\n\nfunc outputOrderbook(ob *models.Orderbook) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\tfmt.Printf(\"\\n%s Orderbook for %s\\n\\n\", ui.TitleStyle.Render(\"YES\"), ob.Ticker)\n\n\t\t// YES side - Bids on left, Asks on right\n\t\tfmt.Println(ui.HeaderStyle.Render(\" BIDS ASKS\"))\n\t\tfmt.Println(ui.MutedStyle.Render(\" Qty Price Price Qty\"))\n\t\tfmt.Println(strings.Repeat(\"-\", 50))\n\n\t\tmaxRows := maxInt(len(ob.YesBids), len(ob.YesAsks))\n\t\tfor i := 0; i \u003c maxRows; i++ {\n\t\t\tbidStr := \" \"\n\t\t\taskStr := \" \"\n\n\t\t\tif i \u003c len(ob.YesBids) {\n\t\t\t\tbid := ob.YesBids[i]\n\t\t\t\tbidStr = fmt.Sprintf(\"%5d %s\", bid.Quantity, formatCents(bid.Price))\n\t\t\t\tbidStr = ui.PriceUpStyle.Render(bidStr)\n\t\t\t}\n\n\t\t\tif i \u003c len(ob.YesAsks) {\n\t\t\t\task := ob.YesAsks[i]\n\t\t\t\taskStr = fmt.Sprintf(\"%s %5d\", formatCents(ask.Price), ask.Quantity)\n\t\t\t\taskStr = ui.PriceDownStyle.Render(askStr)\n\t\t\t}\n\n\t\t\tfmt.Printf(\"%s %s\\n\", bidStr, askStr)\n\t\t}\n\n\t\tfmt.Println()\n\t}\n\n\tplainFunc := func() {\n\t\tfmt.Printf(\"Ticker: %s\\n\", ob.Ticker)\n\t\tfmt.Println(\"YES BIDS:\")\n\t\tfor _, bid := range ob.YesBids {\n\t\t\tfmt.Printf(\" %s x %d\\n\", formatCents(bid.Price), bid.Quantity)\n\t\t}\n\t\tfmt.Println(\"YES ASKS:\")\n\t\tfor _, ask := range ob.YesAsks {\n\t\t\tfmt.Printf(\" %s x %d\\n\", formatCents(ask.Price), ask.Quantity)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, ob, plainFunc)\n}\n\nfunc runMarketsTrades(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tparams := api.GetTradesParams{\n\t\tTicker: ticker,\n\t\tLimit: tradesLimit,\n\t}\n\n\tresult, err := client.GetTrades(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get trades: %w\", err)\n\t}\n\n\treturn outputTrades(result.Trades)\n}\n\nfunc outputTrades(trades []models.Trade) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\theaders := []string{\"Time\", \"Price\", \"Quantity\", \"Side\"}\n\t\tvar rows [][]string\n\n\t\tfor _, t := range trades {\n\t\t\tside := formatTradeSide(t.TakerSide)\n\t\t\trows = append(rows, []string{\n\t\t\t\tformatMarketTime(t.CreatedTime),\n\t\t\t\tformatCents(t.Price),\n\t\t\t\tfmt.Sprintf(\"%d\", t.Count),\n\t\t\t\tside,\n\t\t\t})\n\t\t}\n\n\t\tui.RenderTable(headers, rows)\n\t}\n\n\tplainFunc := func() {\n\t\tfor _, t := range trades {\n\t\t\tfmt.Printf(\"%s\\t%s\\t%d\\t%s\\n\",\n\t\t\t\tt.CreatedTime.Format(time.RFC3339),\n\t\t\t\tformatCents(t.Price),\n\t\t\t\tt.Count,\n\t\t\t\tt.TakerSide,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, trades, plainFunc)\n}\n\nfunc runMarketsCandlesticks(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tparams := api.GetCandlesticksParams{\n\t\tSeriesTicker: candleSeriesTicker,\n\t\tTicker: ticker,\n\t\tPeriod: candlePeriod,\n\t}\n\n\tresult, err := client.GetCandlesticks(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get candlesticks: %w\", err)\n\t}\n\n\treturn outputCandlesticks(result.Candlesticks)\n}\n\nfunc outputCandlesticks(candles []models.Candlestick) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\tui.RenderCandlestickChart(candlesToChartData(candles), \"Candlesticks\")\n\n\t\theaders := []string{\"Time\", \"Open\", \"High\", \"Low\", \"Close\", \"Volume\"}\n\t\tvar rows [][]string\n\n\t\tfor _, c := range candles {\n\t\t\trows = append(rows, []string{\n\t\t\t\tformatMarketTime(c.PeriodEnd),\n\t\t\t\tformatCents(c.Open),\n\t\t\t\tformatCents(c.High),\n\t\t\t\tformatCents(c.Low),\n\t\t\t\tformatCents(c.Close),\n\t\t\t\tfmt.Sprintf(\"%d\", c.Volume),\n\t\t\t})\n\t\t}\n\n\t\tui.RenderTable(headers, rows)\n\t}\n\n\tplainFunc := func() {\n\t\tfor _, c := range candles {\n\t\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\t%s\\t%d\\n\",\n\t\t\t\tc.PeriodEnd.Format(time.RFC3339),\n\t\t\t\tformatCents(c.Open),\n\t\t\t\tformatCents(c.High),\n\t\t\t\tformatCents(c.Low),\n\t\t\t\tformatCents(c.Close),\n\t\t\t\tc.Volume,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, candles, plainFunc)\n}\n\nfunc runSeriesList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tparams := api.ListSeriesParams{\n\t\tCategory: seriesCategory,\n\t\tLimit: seriesLimit,\n\t}\n\n\tresult, err := client.ListSeries(ctx, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list series: %w\", err)\n\t}\n\n\treturn outputSeriesList(result.Series)\n}\n\nfunc outputSeriesList(series []models.Series) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\theaders := []string{\"Ticker\", \"Title\", \"Category\", \"Frequency\"}\n\t\tvar rows [][]string\n\n\t\tfor _, s := range series {\n\t\t\ttitle := truncateMarketString(s.Title, 50)\n\t\t\trows = append(rows, []string{\n\t\t\t\ts.Ticker,\n\t\t\t\ttitle,\n\t\t\t\ts.Category,\n\t\t\t\ts.Frequency,\n\t\t\t})\n\t\t}\n\n\t\tui.RenderTable(headers, rows)\n\t}\n\n\tplainFunc := func() {\n\t\tfor _, s := range series {\n\t\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\n\",\n\t\t\t\ts.Ticker,\n\t\t\t\ts.Title,\n\t\t\t\ts.Category,\n\t\t\t\ts.Frequency,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, series, plainFunc)\n}\n\nfunc runSeriesGet(cmd *cobra.Command, args []string) error {\n\tticker := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tseries, err := client.GetSeries(ctx, ticker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get series: %w\", err)\n\t}\n\n\treturn outputSeriesDetails(series)\n}\n\nfunc outputSeriesDetails(series *models.Series) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\tpairs := [][]string{\n\t\t\t{\"Ticker\", series.Ticker},\n\t\t\t{\"Title\", series.Title},\n\t\t\t{\"Category\", series.Category},\n\t\t\t{\"Frequency\", series.Frequency},\n\t\t\t{\"Tags\", strings.Join(series.Tags, \", \")},\n\t\t}\n\n\t\tui.RenderKeyValue(pairs)\n\t}\n\n\tplainFunc := func() {\n\t\tfmt.Printf(\"Ticker: %s\\n\", series.Ticker)\n\t\tfmt.Printf(\"Title: %s\\n\", series.Title)\n\t\tfmt.Printf(\"Category: %s\\n\", series.Category)\n\t\tfmt.Printf(\"Frequency: %s\\n\", series.Frequency)\n\t\tfmt.Printf(\"Tags: %s\\n\", strings.Join(series.Tags, \", \"))\n\t}\n\n\treturn ui.Output(format, tableFunc, series, plainFunc)\n}\n\n// Helper functions for markets commands\n\n\n\nfunc formatTradeSide(side string) string {\n\tswitch strings.ToLower(side) {\n\tcase \"yes\":\n\t\treturn ui.PriceUpStyle.Render(\"YES\")\n\tcase \"no\":\n\t\treturn ui.PriceDownStyle.Render(\"NO\")\n\tdefault:\n\t\treturn side\n\t}\n}\n\nfunc formatMarketTime(t time.Time) string {\n\tif t.IsZero() {\n\t\treturn \"-\"\n\t}\n\treturn t.Format(\"2006-01-02 15:04:05\")\n}\n\nfunc truncateMarketString(s string, maxLen int) string {\n\trunes := []rune(s)\n\tif len(runes) \u003c= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[:maxLen-3]) + \"...\"\n}\n\nfunc maxInt(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc candlesToChartData(candles []models.Candlestick) []ui.CandleData {\n\tdata := make([]ui.CandleData, len(candles))\n\tfor i, c := range candles {\n\t\tdata[i] = ui.CandleData{\n\t\t\tLabel: c.PeriodEnd.Format(\"01/02 15:04\"),\n\t\t\tOpen: c.Open,\n\t\t\tHigh: c.High,\n\t\t\tLow: c.Low,\n\t\t\tClose: c.Close,\n\t\t\tVolume: c.Volume,\n\t\t}\n\t}\n\treturn data\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":14932,"content_sha256":"05414420b744225a9b81ac478dc285cb6b3d6f8720696c555c1578e758b610eb"},{"filename":"internal/cmd/ordergroups.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar orderGroupsCmd = &cobra.Command{\n\tUse: \"order-groups\",\n\tAliases: []string{\"og\"},\n\tShort: \"Manage order groups\",\n\tLong: `Order groups allow you to group multiple orders together and manage them\nas a single unit. You can set limits on the number of contracts that can\nbe filled across all orders in a group.`,\n}\n\nvar orderGroupsListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List order groups\",\n\tLong: `List all order groups for the authenticated user.`,\n\tRunE: runOrderGroupsList,\n}\n\nvar orderGroupsGetCmd = &cobra.Command{\n\tUse: \"get \u003cgroup-id>\",\n\tShort: \"Get order group details\",\n\tLong: `Get detailed information about a specific order group.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrderGroupsGet,\n}\n\nvar orderGroupsCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create a new order group\",\n\tLong: `Create a new order group with a specified contract limit.\n\nThe limit specifies the maximum number of contracts that can be filled\nacross all orders in the group.`,\n\tRunE: runOrderGroupsCreate,\n}\n\nvar orderGroupsDeleteCmd = &cobra.Command{\n\tUse: \"delete \u003cgroup-id>\",\n\tShort: \"Delete an order group\",\n\tLong: `Delete an order group. All orders in the group will be canceled.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrderGroupsDelete,\n}\n\nvar orderGroupsResetCmd = &cobra.Command{\n\tUse: \"reset \u003cgroup-id>\",\n\tShort: \"Reset an order group\",\n\tLong: `Reset an order group's filled count to zero, allowing more orders to fill.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrderGroupsReset,\n}\n\nvar orderGroupsTriggerCmd = &cobra.Command{\n\tUse: \"trigger \u003cgroup-id>\",\n\tShort: \"Trigger an order group\",\n\tLong: `Trigger an order group to execute its orders.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrderGroupsTrigger,\n}\n\nvar orderGroupsUpdateLimitCmd = &cobra.Command{\n\tUse: \"update-limit \u003cgroup-id>\",\n\tShort: \"Update an order group's contract limit\",\n\tLong: `Update the maximum number of contracts that can be filled across all orders\nin the group. If the new limit is lower than the current filled count,\nthe order group will be triggered.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrderGroupsUpdateLimit,\n}\n\nvar (\n\torderGroupLimit int\n\torderGroupNewLimit int\n\torderGroupStatus string\n)\n\nfunc init() {\n\trootCmd.AddCommand(orderGroupsCmd)\n\n\torderGroupsCmd.AddCommand(orderGroupsListCmd)\n\torderGroupsCmd.AddCommand(orderGroupsGetCmd)\n\torderGroupsCmd.AddCommand(orderGroupsCreateCmd)\n\torderGroupsCmd.AddCommand(orderGroupsDeleteCmd)\n\torderGroupsCmd.AddCommand(orderGroupsResetCmd)\n\torderGroupsCmd.AddCommand(orderGroupsTriggerCmd)\n\torderGroupsCmd.AddCommand(orderGroupsUpdateLimitCmd)\n\n\torderGroupsListCmd.Flags().StringVar(&orderGroupStatus, \"status\", \"\", \"filter by status\")\n\n\torderGroupsCreateCmd.Flags().IntVar(&orderGroupLimit, \"limit\", 0, \"maximum contracts to fill (required)\")\n\torderGroupsCreateCmd.MarkFlagRequired(\"limit\")\n\n\torderGroupsUpdateLimitCmd.Flags().IntVar(&orderGroupNewLimit, \"limit\", 0, \"new maximum contracts to fill (required)\")\n\torderGroupsUpdateLimitCmd.MarkFlagRequired(\"limit\")\n}\n\n// getAPIClient uses createClient from helpers.go\n\nfunc runOrderGroupsList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topts := api.OrderGroupsOptions{\n\t\tStatus: orderGroupStatus,\n\t}\n\n\tresult, err := client.GetOrderGroups(context.Background(), opts)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn outputOrderGroupsList(result.OrderGroups)\n}\n\nfunc runOrderGroupsGet(cmd *cobra.Command, args []string) error {\n\tgroupID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult, err := client.GetOrderGroup(context.Background(), groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn outputOrderGroupDetails(&result.OrderGroup)\n}\n\nfunc runOrderGroupsCreate(cmd *cobra.Command, args []string) error {\n\tif orderGroupLimit \u003c= 0 {\n\t\treturn fmt.Errorf(\"limit must be a positive integer\")\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq := models.CreateOrderGroupRequest{Limit: orderGroupLimit}\n\tresult, err := client.CreateOrderGroup(context.Background(), req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Created order group: %s\", result.OrderGroup.GroupID))\n\treturn outputOrderGroupDetails(&result.OrderGroup)\n}\n\nfunc runOrderGroupsDelete(cmd *cobra.Command, args []string) error {\n\tgroupID := args[0]\n\n\tif !SkipConfirmation() {\n\t\tfmt.Printf(\"Are you sure you want to delete order group %s? (y/N): \", groupID)\n\t\tvar response string\n\t\tfmt.Scanln(&response)\n\t\tif response != \"y\" && response != \"Y\" {\n\t\t\tPrintWarning(\"Deletion canceled\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := client.DeleteOrderGroup(context.Background(), groupID); err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Deleted order group: %s\", groupID))\n\treturn nil\n}\n\nfunc runOrderGroupsReset(cmd *cobra.Command, args []string) error {\n\tgroupID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult, err := client.ResetOrderGroup(context.Background(), groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Reset order group: %s\", groupID))\n\treturn outputOrderGroupDetails(&result.OrderGroup)\n}\n\nfunc runOrderGroupsTrigger(cmd *cobra.Command, args []string) error {\n\tgroupID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult, err := client.TriggerOrderGroup(context.Background(), groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Triggered order group: %s\", groupID))\n\treturn outputOrderGroupDetails(&result.OrderGroup)\n}\n\nfunc runOrderGroupsUpdateLimit(cmd *cobra.Command, args []string) error {\n\tgroupID := args[0]\n\n\tif orderGroupNewLimit \u003c 0 {\n\t\treturn fmt.Errorf(\"limit must be a non-negative integer\")\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult, err := client.UpdateOrderGroupLimit(context.Background(), groupID, orderGroupNewLimit)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Updated order group limit: %s (new limit: %d)\", groupID, orderGroupNewLimit))\n\treturn outputOrderGroupDetails(&result.OrderGroup)\n}\n\nfunc outputOrderGroupsList(groups []models.OrderGroup) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\theaders := []string{\"Group ID\", \"Status\", \"Limit\", \"Filled\", \"Order Count\"}\n\t\trows := make([][]string, 0, len(groups))\n\n\t\tfor _, g := range groups {\n\t\t\trows = append(rows, []string{\n\t\t\t\tg.GroupID,\n\t\t\t\tg.Status,\n\t\t\t\tstrconv.Itoa(g.Limit),\n\t\t\t\tstrconv.Itoa(g.FilledCount),\n\t\t\t\tstrconv.Itoa(g.OrderCount),\n\t\t\t})\n\t\t}\n\n\t\tui.RenderTable(headers, rows)\n\t}\n\n\tplainFunc := func() {\n\t\tfor _, g := range groups {\n\t\t\tui.PrintPlain(\"%s\\t%s\\t%d\\t%d\\t%d\",\n\t\t\t\tg.GroupID, g.Status, g.Limit, g.FilledCount, g.OrderCount)\n\t\t}\n\t}\n\n\treturn ui.Output(format, tableFunc, groups, plainFunc)\n}\n\nfunc outputOrderGroupDetails(group *models.OrderGroup) error {\n\tformat := GetOutputFormat()\n\n\ttableFunc := func() {\n\t\tpairs := [][]string{\n\t\t\t{\"Group ID\", group.GroupID},\n\t\t\t{\"Status\", group.Status},\n\t\t\t{\"Limit\", strconv.Itoa(group.Limit)},\n\t\t\t{\"Filled Count\", strconv.Itoa(group.FilledCount)},\n\t\t\t{\"Order Count\", strconv.Itoa(group.OrderCount)},\n\t\t\t{\"Created\", group.CreatedTime.Format(\"2006-01-02 15:04:05\")},\n\t\t\t{\"Last Updated\", group.LastUpdateTime.Format(\"2006-01-02 15:04:05\")},\n\t\t}\n\n\t\tif len(group.OrderIDs) > 0 {\n\t\t\tpairs = append(pairs, []string{\"Order IDs\", fmt.Sprintf(\"%v\", group.OrderIDs)})\n\t\t}\n\n\t\tui.RenderKeyValue(pairs)\n\t}\n\n\tplainFunc := func() {\n\t\tui.PrintPlain(\"group_id=%s status=%s limit=%d filled=%d orders=%d\",\n\t\t\tgroup.GroupID, group.Status, group.Limit, group.FilledCount, group.OrderCount)\n\t}\n\n\treturn ui.Output(format, tableFunc, group, plainFunc)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7944,"content_sha256":"b1011d46b419fb57d61b93a38437da33aad402f2b509bd807bfe52b0cc43c4d7"},{"filename":"internal/cmd/orders.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar ordersCmd = &cobra.Command{\n\tUse: \"orders\",\n\tShort: \"Manage trading orders\",\n\tLong: `Manage trading orders on the Kalshi exchange.\n\nCommands for listing, creating, canceling, and amending orders.`,\n\tExample: ` kalshi-cli orders list --status resting\n kalshi-cli orders create --market INXD-25FEB07-B5523.99 --side yes --qty 10 --price 50\n kalshi-cli orders cancel ORDER_ID`,\n}\n\nvar ordersListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List orders\",\n\tLong: `List orders with optional filters for status and market ticker.`,\n\tExample: ` kalshi-cli orders list\n kalshi-cli orders list --status resting\n kalshi-cli orders list --market INXD-25FEB07-B5523.99 --json`,\n\tRunE: runOrdersList,\n}\n\nvar ordersGetCmd = &cobra.Command{\n\tUse: \"get \u003corder-id>\",\n\tShort: \"Get order details\",\n\tLong: `Get detailed information about a specific order.`,\n\tExample: ` kalshi-cli orders get abc123-def456-ghi789`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrdersGet,\n}\n\nvar ordersCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create a new order\",\n\tLong: `Create a new limit order on a market.\n\nThe order preview will be shown before submission. You must confirm\nunless the --yes flag is set.\n\nPrice must be between 1-99 cents.`,\n\tExample: ` kalshi-cli orders create --market INXD-25FEB07-B5523.99 --side yes --qty 10 --price 50\n kalshi-cli orders create --market INXD-25FEB07-B5523.99 --side no --qty 5 --price 30 --action sell\n kalshi-cli orders create --market INXD-25FEB07-B5523.99 --side yes --qty 10 --price 50 --yes`,\n\tRunE: runOrdersCreate,\n}\n\nvar ordersCancelCmd = &cobra.Command{\n\tUse: \"cancel \u003corder-id>\",\n\tShort: \"Cancel an order\",\n\tLong: `Cancel a resting order by its ID.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrdersCancel,\n}\n\nvar ordersCancelAllCmd = &cobra.Command{\n\tUse: \"cancel-all\",\n\tShort: \"Cancel all orders\",\n\tLong: `Cancel all resting orders, optionally filtered by market ticker.`,\n\tRunE: runOrdersCancelAll,\n}\n\nvar ordersAmendCmd = &cobra.Command{\n\tUse: \"amend \u003corder-id>\",\n\tShort: \"Amend an existing order\",\n\tLong: `Amend an existing order's quantity and/or price.\n\nAt least one of --qty or --price must be specified.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrdersAmend,\n}\n\nvar ordersBatchCreateCmd = &cobra.Command{\n\tUse: \"batch-create\",\n\tShort: \"Create multiple orders from a file\",\n\tLong: `Create multiple orders from a JSON file.\n\nThe JSON file should contain an array of order objects with fields:\n- ticker: Market ticker (required)\n- side: \"yes\" or \"no\" (required)\n- action: \"buy\" or \"sell\" (required)\n- type: \"limit\" or \"market\" (required)\n- count: Quantity (required)\n- yes_price: Price in cents for yes side (optional)\n- no_price: Price in cents for no side (optional)`,\n\tRunE: runOrdersBatchCreate,\n}\n\nvar ordersQueueCmd = &cobra.Command{\n\tUse: \"queue \u003corder-id>\",\n\tShort: \"Get queue position for an order\",\n\tLong: `Get the queue position for a resting order.`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runOrdersQueue,\n}\n\n// Flags\nvar (\n\torderStatusFilter string\n\torderMarketFilter string\n\torderCreateMarket string\n\torderCancelAllMarket string\n\torderSide string\n\torderCreateQty int\n\torderCreatePrice int\n\torderAmendQty int\n\torderAmendPrice int\n\torderAction string\n\torderType string\n\tbatchFile string\n)\n\nfunc init() {\n\trootCmd.AddCommand(ordersCmd)\n\n\tordersCmd.AddCommand(ordersListCmd)\n\tordersCmd.AddCommand(ordersGetCmd)\n\tordersCmd.AddCommand(ordersCreateCmd)\n\tordersCmd.AddCommand(ordersCancelCmd)\n\tordersCmd.AddCommand(ordersCancelAllCmd)\n\tordersCmd.AddCommand(ordersAmendCmd)\n\tordersCmd.AddCommand(ordersBatchCreateCmd)\n\tordersCmd.AddCommand(ordersQueueCmd)\n\n\t// List flags\n\tordersListCmd.Flags().StringVar(&orderStatusFilter, \"status\", \"\", \"filter by status (resting, canceled, executed, pending)\")\n\tordersListCmd.Flags().StringVar(&orderMarketFilter, \"market\", \"\", \"filter by market ticker\")\n\n\t// Create flags\n\tordersCreateCmd.Flags().StringVar(&orderCreateMarket, \"market\", \"\", \"market ticker (required)\")\n\tordersCreateCmd.Flags().StringVar(&orderSide, \"side\", \"\", \"order side: yes or no (required)\")\n\tordersCreateCmd.Flags().IntVar(&orderCreateQty, \"qty\", 0, \"quantity (required)\")\n\tordersCreateCmd.Flags().IntVar(&orderCreatePrice, \"price\", 0, \"price in cents 1-99 (required)\")\n\tordersCreateCmd.Flags().StringVar(&orderAction, \"action\", \"buy\", \"order action: buy or sell (default: buy)\")\n\tordersCreateCmd.Flags().StringVar(&orderType, \"type\", \"limit\", \"order type: limit or market (default: limit)\")\n\tordersCreateCmd.MarkFlagRequired(\"market\")\n\tordersCreateCmd.MarkFlagRequired(\"side\")\n\tordersCreateCmd.MarkFlagRequired(\"qty\")\n\tordersCreateCmd.MarkFlagRequired(\"price\")\n\n\t// Cancel all flags\n\tordersCancelAllCmd.Flags().StringVar(&orderCancelAllMarket, \"market\", \"\", \"filter by market ticker\")\n\n\t// Amend flags\n\tordersAmendCmd.Flags().IntVar(&orderAmendQty, \"qty\", 0, \"new quantity\")\n\tordersAmendCmd.Flags().IntVar(&orderAmendPrice, \"price\", 0, \"new price in cents\")\n\n\t// Batch create flags\n\tordersBatchCreateCmd.Flags().StringVar(&batchFile, \"file\", \"\", \"path to JSON file containing orders (required)\")\n\tordersBatchCreateCmd.MarkFlagRequired(\"file\")\n}\n\n// createAPIClient is defined in helpers.go\n\nfunc getEnvironmentLabel() string {\n\tcfg := GetConfig()\n\tif cfg.API.Production {\n\t\treturn ui.ProdStyle.Render(\" PRODUCTION \")\n\t}\n\treturn ui.DemoStyle.Render(\" DEMO \")\n}\n\n\nfunc runOrdersList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tparams := make(map[string]string)\n\tif orderStatusFilter != \"\" {\n\t\tparams[\"status\"] = orderStatusFilter\n\t}\n\tif orderMarketFilter != \"\" {\n\t\tparams[\"ticker\"] = orderMarketFilter\n\t}\n\n\tvar response models.OrdersResponse\n\tpath := \"/trade-api/v2/portfolio/orders\"\n\tif len(params) > 0 {\n\t\tpath += api.BuildQueryString(params)\n\t}\n\n\tif err := client.GetJSON(ctx, path, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to list orders: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrdersTable(response.Orders) },\n\t\tresponse.Orders,\n\t\tfunc() { renderOrdersPlain(response.Orders) },\n\t)\n}\n\nfunc runOrdersGet(cmd *cobra.Command, args []string) error {\n\torderID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tvar response models.OrderResponse\n\tpath := fmt.Sprintf(\"/trade-api/v2/portfolio/orders/%s\", orderID)\n\n\tif err := client.GetJSON(ctx, path, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to get order: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrderDetails(response.Order) },\n\t\tresponse.Order,\n\t\tfunc() { renderOrderPlain(response.Order) },\n\t)\n}\n\nfunc runOrdersCreate(cmd *cobra.Command, args []string) error {\n\t// Validate price range\n\tif orderCreatePrice \u003c 1 || orderCreatePrice > 99 {\n\t\treturn fmt.Errorf(\"price must be between 1 and 99 cents, got %d\", orderCreatePrice)\n\t}\n\n\t// Validate side\n\tside := strings.ToLower(orderSide)\n\tif side != \"yes\" && side != \"no\" {\n\t\treturn fmt.Errorf(\"side must be 'yes' or 'no', got '%s'\", orderSide)\n\t}\n\n\t// Validate action\n\taction := strings.ToLower(orderAction)\n\tif action != \"buy\" && action != \"sell\" {\n\t\treturn fmt.Errorf(\"action must be 'buy' or 'sell', got '%s'\", orderAction)\n\t}\n\n\t// Validate type\n\toType := strings.ToLower(orderType)\n\tif oType != \"limit\" && oType != \"market\" {\n\t\treturn fmt.Errorf(\"type must be 'limit' or 'market', got '%s'\", orderType)\n\t}\n\n\t// Validate quantity\n\tif orderCreateQty \u003c= 0 {\n\t\treturn fmt.Errorf(\"quantity must be positive, got %d\", orderCreateQty)\n\t}\n\n\t// Build order request\n\torderReq := models.CreateOrderRequest{\n\t\tTicker: orderCreateMarket,\n\t\tSide: models.OrderSide(side),\n\t\tAction: models.OrderAction(action),\n\t\tType: models.OrderType(oType),\n\t\tCount: orderCreateQty,\n\t}\n\n\tif side == \"yes\" {\n\t\torderReq.YesPrice = orderCreatePrice\n\t} else {\n\t\torderReq.NoPrice = orderCreatePrice\n\t}\n\n\t// Show order preview\n\tfmt.Println()\n\tfmt.Println(ui.HeaderStyle.Render(\"Order Preview\"))\n\tfmt.Println()\n\tfmt.Printf(\" Environment: %s\\n\", getEnvironmentLabel())\n\tfmt.Printf(\" Market: %s\\n\", orderReq.Ticker)\n\tfmt.Printf(\" Side: %s\\n\", strings.ToUpper(side))\n\tfmt.Printf(\" Action: %s\\n\", strings.ToUpper(action))\n\tfmt.Printf(\" Type: %s\\n\", strings.ToUpper(oType))\n\tfmt.Printf(\" Quantity: %d contracts\\n\", orderReq.Count)\n\tfmt.Printf(\" Price: %d cents\\n\", orderCreatePrice)\n\n\t// Calculate potential cost/payout\n\tpotentialCost := orderCreateQty * orderCreatePrice\n\tpotentialPayout := orderCreateQty * 100\n\n\tif action == \"buy\" {\n\t\tfmt.Printf(\" Max Cost: %s\\n\", ui.FormatPrice(potentialCost))\n\t\tfmt.Printf(\" Max Payout: %s\\n\", ui.FormatPrice(potentialPayout))\n\t} else {\n\t\tfmt.Printf(\" Max Credit: %s\\n\", ui.FormatPrice(potentialCost))\n\t}\n\tfmt.Println()\n\n\t// Confirm unless --yes flag\n\tcfg := GetConfig()\n\tenvWarning := \"\"\n\tif cfg.API.Production {\n\t\tenvWarning = \" (PRODUCTION - real money)\"\n\t}\n\n\tif !confirmAction(fmt.Sprintf(\"Submit this order%s?\", envWarning)) {\n\t\tPrintWarning(\"Order cancelled\")\n\t\treturn nil\n\t}\n\n\t// Submit order\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tvar response models.CreateOrderResponse\n\tif err := client.PostJSON(ctx, \"/trade-api/v2/portfolio/orders\", orderReq, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to create order: %w\", err)\n\t}\n\n\tPrintSuccess(\"Order created successfully!\")\n\tfmt.Printf(\"Order ID: %s\\n\", response.Order.OrderID)\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrderDetails(response.Order) },\n\t\tresponse.Order,\n\t\tfunc() { renderOrderPlain(response.Order) },\n\t)\n}\n\nfunc runOrdersCancel(cmd *cobra.Command, args []string) error {\n\torderID := args[0]\n\n\tif !confirmAction(fmt.Sprintf(\"Cancel order %s?\", orderID)) {\n\t\tPrintWarning(\"Cancellation aborted\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tvar response models.OrderResponse\n\tpath := fmt.Sprintf(\"/trade-api/v2/portfolio/orders/%s\", orderID)\n\n\tif err := client.DeleteJSON(ctx, path, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to cancel order: %w\", err)\n\t}\n\n\tPrintSuccess(\"Order cancelled successfully!\")\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrderDetails(response.Order) },\n\t\tresponse.Order,\n\t\tfunc() { renderOrderPlain(response.Order) },\n\t)\n}\n\nfunc runOrdersCancelAll(cmd *cobra.Command, args []string) error {\n\tticker := orderCancelAllMarket\n\n\tprompt := \"Cancel ALL resting orders?\"\n\tif ticker != \"\" {\n\t\tprompt = fmt.Sprintf(\"Cancel all resting orders for market %s?\", ticker)\n\t}\n\n\tif !confirmAction(prompt) {\n\t\tPrintWarning(\"Cancellation aborted\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq := models.BatchCancelOrdersRequest{}\n\tif ticker != \"\" {\n\t\treq.Ticker = ticker\n\t}\n\n\tvar response models.BatchCancelOrdersResponse\n\tif err := client.DeleteWithBody(ctx, \"/trade-api/v2/portfolio/orders\", req, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to cancel orders: %w\", err)\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Cancelled %d orders\", len(response.Orders)))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrdersTable(response.Orders) },\n\t\tresponse.Orders,\n\t\tfunc() { renderOrdersPlain(response.Orders) },\n\t)\n}\n\nfunc runOrdersAmend(cmd *cobra.Command, args []string) error {\n\torderID := args[0]\n\n\tif orderAmendQty == 0 && orderAmendPrice == 0 {\n\t\treturn fmt.Errorf(\"at least one of --qty or --price must be specified\")\n\t}\n\n\tif orderAmendPrice != 0 && (orderAmendPrice \u003c 1 || orderAmendPrice > 99) {\n\t\treturn fmt.Errorf(\"price must be between 1 and 99 cents, got %d\", orderAmendPrice)\n\t}\n\n\t// Build amend request\n\tamendReq := models.AmendOrderRequest{}\n\tif orderAmendQty > 0 {\n\t\tamendReq.Count = orderAmendQty\n\t}\n\tif orderAmendPrice > 0 {\n\t\tamendReq.Price = orderAmendPrice\n\t}\n\n\t// Show amendment preview\n\tfmt.Println()\n\tfmt.Println(ui.HeaderStyle.Render(\"Amend Order Preview\"))\n\tfmt.Println()\n\tfmt.Printf(\" Environment: %s\\n\", getEnvironmentLabel())\n\tfmt.Printf(\" Order ID: %s\\n\", orderID)\n\tif orderAmendQty > 0 {\n\t\tfmt.Printf(\" New Quantity: %d contracts\\n\", orderAmendQty)\n\t}\n\tif orderAmendPrice > 0 {\n\t\tfmt.Printf(\" New Price: %d cents\\n\", orderAmendPrice)\n\t}\n\tfmt.Println()\n\n\tif !confirmAction(\"Amend this order?\") {\n\t\tPrintWarning(\"Amendment cancelled\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tvar response models.OrderResponse\n\tpath := fmt.Sprintf(\"/trade-api/v2/portfolio/orders/%s\", orderID)\n\n\tif err := client.PatchJSON(ctx, path, amendReq, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to amend order: %w\", err)\n\t}\n\n\tPrintSuccess(\"Order amended successfully!\")\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrderDetails(response.Order) },\n\t\tresponse.Order,\n\t\tfunc() { renderOrderPlain(response.Order) },\n\t)\n}\n\nfunc runOrdersBatchCreate(cmd *cobra.Command, args []string) error {\n\t// Read and parse the JSON file\n\tdata, err := os.ReadFile(batchFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tvar orders []models.CreateOrderRequest\n\tif err := json.Unmarshal(data, &orders); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse JSON: %w\", err)\n\t}\n\n\tif len(orders) == 0 {\n\t\treturn fmt.Errorf(\"no orders found in file\")\n\t}\n\n\t// Validate all orders\n\tfor i, order := range orders {\n\t\tif order.Ticker == \"\" {\n\t\t\treturn fmt.Errorf(\"order %d: ticker is required\", i+1)\n\t\t}\n\t\tif order.Side != models.OrderSideYes && order.Side != models.OrderSideNo {\n\t\t\treturn fmt.Errorf(\"order %d: side must be 'yes' or 'no'\", i+1)\n\t\t}\n\t\tif order.Count \u003c= 0 {\n\t\t\treturn fmt.Errorf(\"order %d: count must be positive\", i+1)\n\t\t}\n\t\tif order.YesPrice > 0 && (order.YesPrice \u003c 1 || order.YesPrice > 99) {\n\t\t\treturn fmt.Errorf(\"order %d: yes_price must be between 1 and 99\", i+1)\n\t\t}\n\t\tif order.NoPrice > 0 && (order.NoPrice \u003c 1 || order.NoPrice > 99) {\n\t\t\treturn fmt.Errorf(\"order %d: no_price must be between 1 and 99\", i+1)\n\t\t}\n\t}\n\n\t// Show preview\n\tfmt.Println()\n\tfmt.Println(ui.HeaderStyle.Render(\"Batch Order Preview\"))\n\tfmt.Println()\n\tfmt.Printf(\" Environment: %s\\n\", getEnvironmentLabel())\n\tfmt.Printf(\" Total Orders: %d\\n\", len(orders))\n\tfmt.Println()\n\n\t// Show each order\n\tfor i, order := range orders {\n\t\tprice := order.YesPrice\n\t\tif order.Side == models.OrderSideNo {\n\t\t\tprice = order.NoPrice\n\t\t}\n\t\tfmt.Printf(\" %d. %s %s %s @ %d cents x %d\\n\",\n\t\t\ti+1,\n\t\t\tstrings.ToUpper(string(order.Action)),\n\t\t\tstrings.ToUpper(string(order.Side)),\n\t\t\torder.Ticker,\n\t\t\tprice,\n\t\t\torder.Count,\n\t\t)\n\t}\n\tfmt.Println()\n\n\tcfg := GetConfig()\n\tenvWarning := \"\"\n\tif cfg.API.Production {\n\t\tenvWarning = \" (PRODUCTION - real money)\"\n\t}\n\n\tif !confirmAction(fmt.Sprintf(\"Submit %d orders%s?\", len(orders), envWarning)) {\n\t\tPrintWarning(\"Batch order cancelled\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tbatchReq := models.BatchCreateOrdersRequest{Orders: orders}\n\tvar response models.BatchCreateOrdersResponse\n\n\tif err := client.PostJSON(ctx, \"/trade-api/v2/portfolio/orders/batched\", batchReq, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to create batch orders: %w\", err)\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Created %d orders successfully!\", len(response.Orders)))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderOrdersTable(response.Orders) },\n\t\tresponse.Orders,\n\t\tfunc() { renderOrdersPlain(response.Orders) },\n\t)\n}\n\nfunc runOrdersQueue(cmd *cobra.Command, args []string) error {\n\torderID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tvar response models.QueuePositionsResponse\n\tpath := fmt.Sprintf(\"/trade-api/v2/portfolio/orders/%s/queue-position\", orderID)\n\n\tif err := client.GetJSON(ctx, path, &response); err != nil {\n\t\treturn fmt.Errorf(\"failed to get queue position: %w\", err)\n\t}\n\n\tif len(response.Positions) == 0 {\n\t\treturn fmt.Errorf(\"no queue position found for order %s\", orderID)\n\t}\n\n\tposition := response.Positions[0]\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\tfmt.Println()\n\t\t\tfmt.Println(ui.HeaderStyle.Render(\"Queue Position\"))\n\t\t\tfmt.Println()\n\t\t\tfmt.Printf(\" Order ID: %s\\n\", position.OrderID)\n\t\t\tfmt.Printf(\" Position: %d\\n\", position.QueuePosition)\n\t\t\tfmt.Println()\n\t\t},\n\t\tposition,\n\t\tfunc() {\n\t\t\tfmt.Printf(\"%s\\t%d\\n\", position.OrderID, position.QueuePosition)\n\t\t},\n\t)\n}\n\n// Render functions\n\nfunc renderOrdersTable(orders []models.Order) {\n\tif len(orders) == 0 {\n\t\tfmt.Println(\"No orders found\")\n\t\treturn\n\t}\n\n\theaders := []string{\"Order ID\", \"Market\", \"Side\", \"Price\", \"Qty\", \"Status\", \"Created\"}\n\trows := make([][]string, 0, len(orders))\n\n\tfor _, order := range orders {\n\t\tprice := order.YesPrice\n\t\tif order.Side == models.OrderSideNo {\n\t\t\tprice = order.NoPrice\n\t\t}\n\n\t\trows = append(rows, []string{\n\t\t\ttruncateOrderID(order.OrderID),\n\t\t\torder.Ticker,\n\t\t\tstrings.ToUpper(string(order.Side)),\n\t\t\tfmt.Sprintf(\"%d\", price),\n\t\t\tfmt.Sprintf(\"%d/%d\", order.RemainingCount, order.InitialCount),\n\t\t\tformatOrderStatusModel(order.Status),\n\t\t\torder.CreatedTime.Format(\"2006-01-02 15:04\"),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderOrdersPlain(orders []models.Order) {\n\tfor _, order := range orders {\n\t\tprice := order.YesPrice\n\t\tif order.Side == models.OrderSideNo {\n\t\t\tprice = order.NoPrice\n\t\t}\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%d\\t%d\\t%s\\n\",\n\t\t\torder.OrderID,\n\t\t\torder.Ticker,\n\t\t\torder.Side,\n\t\t\tprice,\n\t\t\torder.RemainingCount,\n\t\t\torder.Status,\n\t\t)\n\t}\n}\n\nfunc renderOrderDetails(order models.Order) {\n\tprice := order.YesPrice\n\tif order.Side == models.OrderSideNo {\n\t\tprice = order.NoPrice\n\t}\n\n\tpairs := [][]string{\n\t\t{\"Order ID\", order.OrderID},\n\t\t{\"Market\", order.Ticker},\n\t\t{\"Status\", formatOrderStatusModel(order.Status)},\n\t\t{\"Side\", strings.ToUpper(string(order.Side))},\n\t\t{\"Action\", strings.ToUpper(string(order.Action))},\n\t\t{\"Type\", strings.ToUpper(string(order.Type))},\n\t\t{\"Price\", fmt.Sprintf(\"%d cents\", price)},\n\t\t{\"Initial Qty\", fmt.Sprintf(\"%d\", order.InitialCount)},\n\t\t{\"Remaining Qty\", fmt.Sprintf(\"%d\", order.RemainingCount)},\n\t\t{\"Filled Qty\", fmt.Sprintf(\"%d\", order.FillCount)},\n\t\t{\"Created\", order.CreatedTime.Format(\"2006-01-02 15:04:05\")},\n\t\t{\"Last Updated\", order.LastUpdateTime.Format(\"2006-01-02 15:04:05\")},\n\t}\n\n\tif order.TakerFillCost > 0 || order.TakerFees > 0 {\n\t\tpairs = append(pairs, []string{\"Taker Fills\", fmt.Sprintf(\"%d\", order.TakerFillCount)})\n\t\tpairs = append(pairs, []string{\"Taker Cost\", ui.FormatPrice(order.TakerFillCost)})\n\t\tpairs = append(pairs, []string{\"Taker Fees\", ui.FormatPrice(order.TakerFees)})\n\t}\n\n\tif order.MakerFillCost > 0 || order.MakerFees > 0 {\n\t\tpairs = append(pairs, []string{\"Maker Fills\", fmt.Sprintf(\"%d\", order.MakerFillCount)})\n\t\tpairs = append(pairs, []string{\"Maker Cost\", ui.FormatPrice(order.MakerFillCost)})\n\t\tpairs = append(pairs, []string{\"Maker Fees\", ui.FormatPrice(order.MakerFees)})\n\t}\n\n\tif order.ClientOrderID != \"\" {\n\t\tpairs = append(pairs, []string{\"Client Order ID\", order.ClientOrderID})\n\t}\n\n\tif order.OrderGroupID != \"\" {\n\t\tpairs = append(pairs, []string{\"Order Group ID\", order.OrderGroupID})\n\t}\n\n\tfmt.Println()\n\tui.RenderKeyValue(pairs)\n\tfmt.Println()\n}\n\nfunc renderOrderPlain(order models.Order) {\n\tprice := order.YesPrice\n\tif order.Side == models.OrderSideNo {\n\t\tprice = order.NoPrice\n\t}\n\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\t%d\\t%d/%d\\t%s\\n\",\n\t\torder.OrderID,\n\t\torder.Ticker,\n\t\torder.Side,\n\t\torder.Status,\n\t\tprice,\n\t\torder.RemainingCount,\n\t\torder.InitialCount,\n\t\torder.CreatedTime.Format(\"2006-01-02T15:04:05Z\"),\n\t)\n}\n\nfunc formatOrderStatusModel(status models.OrderStatus) string {\n\tswitch status {\n\tcase models.OrderStatusResting:\n\t\treturn ui.StatusActiveStyle.Render(\"RESTING\")\n\tcase models.OrderStatusExecuted:\n\t\treturn ui.SuccessStyle.Render(\"EXECUTED\")\n\tcase models.OrderStatusCanceled:\n\t\treturn ui.MutedStyle.Render(\"CANCELED\")\n\tcase models.OrderStatusPending:\n\t\treturn ui.WarningStyle.Render(\"PENDING\")\n\tdefault:\n\t\treturn string(status)\n\t}\n}\n\nfunc truncateOrderID(id string) string {\n\tif len(id) > 12 {\n\t\treturn id[:12] + \"...\"\n\t}\n\treturn id\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":20928,"content_sha256":"9f35011a6bb68500a05998e44c89c6b72de63e90476c3398b28aec0d7f2da32e"},{"filename":"internal/cmd/portfolio.go","content":"package cmd\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar portfolioCmd = &cobra.Command{\n\tUse: \"portfolio\",\n\tShort: \"Manage your portfolio and account\",\n\tLong: `View and manage your Kalshi portfolio including balance, positions,\nfills, settlements, and subaccounts.`,\n\tExample: ` kalshi-cli portfolio balance\n kalshi-cli portfolio positions\n kalshi-cli portfolio fills --limit 10\n kalshi-cli portfolio settlements --limit 5`,\n}\n\nvar balanceCmd = &cobra.Command{\n\tUse: \"balance\",\n\tShort: \"Show account balance\",\n\tLong: `Display your current account balance including available balance, portfolio value, and total balance.\n\nAll values are in cents.`,\n\tExample: ` kalshi-cli portfolio balance\n kalshi-cli portfolio balance --json`,\n\tRunE: runBalance,\n}\n\nvar positionsCmd = &cobra.Command{\n\tUse: \"positions\",\n\tShort: \"List positions\",\n\tLong: `List your current market positions with details including average cost, P&L, and exposure.`,\n\tExample: ` kalshi-cli portfolio positions\n kalshi-cli portfolio positions --market INXD-25FEB07-B5523.99`,\n\tRunE: runPositions,\n}\n\nvar fillsCmd = &cobra.Command{\n\tUse: \"fills\",\n\tShort: \"List fills\",\n\tLong: `List your trade fills showing executed orders and their details.`,\n\tExample: ` kalshi-cli portfolio fills\n kalshi-cli portfolio fills --limit 20`,\n\tRunE: runFills,\n}\n\nvar settlementsCmd = &cobra.Command{\n\tUse: \"settlements\",\n\tShort: \"List settlements\",\n\tLong: `List your market settlements showing resolved positions and their outcomes.`,\n\tExample: ` kalshi-cli portfolio settlements\n kalshi-cli portfolio settlements --limit 10`,\n\tRunE: runSettlements,\n}\n\nvar subaccountsCmd = &cobra.Command{\n\tUse: \"subaccounts\",\n\tShort: \"Manage subaccounts\",\n\tLong: `List, create, and manage subaccounts for your Kalshi account.`,\n}\n\nvar subaccountsListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List subaccounts\",\n\tLong: `List all subaccounts associated with your account.`,\n\tRunE: runSubaccountsList,\n}\n\nvar subaccountsCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create subaccount\",\n\tLong: `Create a new subaccount.`,\n\tRunE: runSubaccountsCreate,\n}\n\nvar subaccountsTransferCmd = &cobra.Command{\n\tUse: \"transfer\",\n\tShort: \"Transfer between subaccounts\",\n\tLong: `Transfer funds between subaccounts. Requires --from, --to, and --amount flags.`,\n\tRunE: runSubaccountsTransfer,\n}\n\nvar (\n\tpositionsMarket string\n\tfillsLimit int\n\tsettlementsLimit int\n\ttransferFrom int\n\ttransferTo int\n\ttransferAmount int\n)\n\nfunc init() {\n\trootCmd.AddCommand(portfolioCmd)\n\n\tportfolioCmd.AddCommand(balanceCmd)\n\tportfolioCmd.AddCommand(positionsCmd)\n\tportfolioCmd.AddCommand(fillsCmd)\n\tportfolioCmd.AddCommand(settlementsCmd)\n\tportfolioCmd.AddCommand(subaccountsCmd)\n\n\tsubaccountsCmd.AddCommand(subaccountsListCmd)\n\tsubaccountsCmd.AddCommand(subaccountsCreateCmd)\n\tsubaccountsCmd.AddCommand(subaccountsTransferCmd)\n\n\tpositionsCmd.Flags().StringVar(&positionsMarket, \"market\", \"\", \"filter by market ticker\")\n\n\tfillsCmd.Flags().IntVar(&fillsLimit, \"limit\", 100, \"maximum number of fills to return\")\n\n\tsettlementsCmd.Flags().IntVar(&settlementsLimit, \"limit\", 50, \"maximum number of settlements to return\")\n\n\tsubaccountsTransferCmd.Flags().IntVar(&transferFrom, \"from\", 0, \"source subaccount ID\")\n\tsubaccountsTransferCmd.Flags().IntVar(&transferTo, \"to\", 0, \"destination subaccount ID\")\n\tsubaccountsTransferCmd.Flags().IntVar(&transferAmount, \"amount\", 0, \"amount to transfer in cents\")\n\tsubaccountsTransferCmd.MarkFlagRequired(\"from\")\n\tsubaccountsTransferCmd.MarkFlagRequired(\"to\")\n\tsubaccountsTransferCmd.MarkFlagRequired(\"amount\")\n}\n\nfunc runBalance(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tbalance, err := client.GetBalance(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get balance: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderBalanceTable(balance) },\n\t\tbalance,\n\t\tfunc() { renderBalancePlain(balance) },\n\t)\n}\n\nfunc renderBalanceTable(balance *models.BalanceResponse) {\n\tpairs := [][]string{\n\t\t{ui.BoldStyle.Render(\"Available Balance:\"), ui.FormatPrice(balance.Balance)},\n\t\t{ui.BoldStyle.Render(\"Portfolio Value:\"), ui.FormatPrice(balance.PortfolioValue)},\n\t\t{ui.BoldStyle.Render(\"Total Balance:\"), ui.FormatPrice(balance.Balance + balance.PortfolioValue)},\n\t}\n\n\tui.RenderKeyValue(pairs)\n}\n\nfunc renderBalancePlain(balance *models.BalanceResponse) {\n\tui.PrintPlain(\"Available Balance: %s\", ui.FormatPrice(balance.Balance))\n\tui.PrintPlain(\"Portfolio Value: %s\", ui.FormatPrice(balance.PortfolioValue))\n\tui.PrintPlain(\"Total Balance: %s\", ui.FormatPrice(balance.Balance+balance.PortfolioValue))\n}\n\nfunc runPositions(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\topts := api.PositionsOptions{\n\t\tTicker: positionsMarket,\n\t}\n\n\tpositions, err := client.GetPositions(ctx, opts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get positions: %w\", err)\n\t}\n\n\tif len(positions.Positions) == 0 {\n\t\tPrintWarning(\"No positions found\")\n\t\treturn nil\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderPositionsTable(positions.Positions) },\n\t\tpositions,\n\t\tfunc() { renderPositionsPlain(positions.Positions) },\n\t)\n}\n\nfunc renderPositionsTable(positions []models.MarketPosition) {\n\theaders := []string{\"Market\", \"Position\", \"Avg Cost\", \"P&L\", \"Exposure\"}\n\trows := make([][]string, 0, len(positions))\n\n\tfor _, p := range positions {\n\t\tavgCost := calculateAvgCost(p)\n\t\tpnl := p.RealizedPnl\n\n\t\tpnlStr := ui.FormatPriceStyled(pnl, pnl >= 0)\n\n\t\trows = append(rows, []string{\n\t\t\tp.Ticker,\n\t\t\tformatPosition(p.Position),\n\t\t\tui.FormatPrice(avgCost),\n\t\t\tpnlStr,\n\t\t\tui.FormatPrice(p.MarketExposure),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderPositionsPlain(positions []models.MarketPosition) {\n\tfor _, p := range positions {\n\t\tavgCost := calculateAvgCost(p)\n\t\tui.PrintPlain(\"%s\\t%s\\t%s\\t%s\\t%s\",\n\t\t\tp.Ticker,\n\t\t\tformatPosition(p.Position),\n\t\t\tui.FormatPrice(avgCost),\n\t\t\tui.FormatPrice(p.RealizedPnl),\n\t\t\tui.FormatPrice(p.MarketExposure),\n\t\t)\n\t}\n}\n\nfunc calculateAvgCost(p models.MarketPosition) int {\n\tif p.Position == 0 {\n\t\treturn 0\n\t}\n\treturn p.TotalTraded / abs(p.Position)\n}\n\nfunc formatPosition(position int) string {\n\tif position == 0 {\n\t\treturn \"0\"\n\t}\n\tif position > 0 {\n\t\treturn fmt.Sprintf(\"+%d\", position)\n\t}\n\treturn fmt.Sprintf(\"-%d\", abs(position))\n}\n\nfunc abs(n int) int {\n\tif n \u003c 0 {\n\t\treturn -n\n\t}\n\treturn n\n}\n\nfunc runFills(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\topts := api.FillsOptions{\n\t\tLimit: fillsLimit,\n\t}\n\n\tfills, err := client.GetFills(ctx, opts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get fills: %w\", err)\n\t}\n\n\tif len(fills.Fills) == 0 {\n\t\tPrintWarning(\"No fills found\")\n\t\treturn nil\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderFillsTable(fills.Fills) },\n\t\tfills,\n\t\tfunc() { renderFillsPlain(fills.Fills) },\n\t)\n}\n\nfunc renderFillsTable(fills []models.Fill) {\n\theaders := []string{\"Time\", \"Ticker\", \"Side\", \"Action\", \"Count\", \"Price\", \"Taker\"}\n\trows := make([][]string, 0, len(fills))\n\n\tfor _, f := range fills {\n\t\tprice := f.YesPrice\n\t\tif f.Side == \"no\" {\n\t\t\tprice = f.NoPrice\n\t\t}\n\n\t\ttakerStr := \"No\"\n\t\tif f.IsTaker {\n\t\t\ttakerStr = \"Yes\"\n\t\t}\n\n\t\trows = append(rows, []string{\n\t\t\tf.CreatedTime.Format(\"2006-01-02 15:04\"),\n\t\t\tf.Ticker,\n\t\t\tstrings.ToUpper(f.Side),\n\t\t\tstrings.ToUpper(f.Action),\n\t\t\tstrconv.Itoa(f.Count),\n\t\t\tui.FormatPrice(price),\n\t\t\ttakerStr,\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderFillsPlain(fills []models.Fill) {\n\tfor _, f := range fills {\n\t\tprice := f.YesPrice\n\t\tif f.Side == \"no\" {\n\t\t\tprice = f.NoPrice\n\t\t}\n\t\tui.PrintPlain(\"%s\\t%s\\t%s\\t%s\\t%d\\t%s\",\n\t\t\tf.CreatedTime.Format(\"2006-01-02T15:04:05Z\"),\n\t\t\tf.Ticker,\n\t\t\tf.Side,\n\t\t\tf.Action,\n\t\t\tf.Count,\n\t\t\tui.FormatPrice(price),\n\t\t)\n\t}\n}\n\nfunc runSettlements(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\topts := api.SettlementsOptions{\n\t\tLimit: settlementsLimit,\n\t}\n\n\tsettlements, err := client.GetSettlements(ctx, opts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get settlements: %w\", err)\n\t}\n\n\tif len(settlements.Settlements) == 0 {\n\t\tPrintWarning(\"No settlements found\")\n\t\treturn nil\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderSettlementsTable(settlements.Settlements) },\n\t\tsettlements,\n\t\tfunc() { renderSettlementsPlain(settlements.Settlements) },\n\t)\n}\n\nfunc renderSettlementsTable(settlements []models.Settlement) {\n\theaders := []string{\"Settled\", \"Ticker\", \"Result\", \"Revenue\", \"Yes Qty\", \"No Qty\"}\n\trows := make([][]string, 0, len(settlements))\n\n\tfor _, s := range settlements {\n\t\trevenueStr := ui.FormatPriceStyled(s.Revenue, s.Revenue >= 0)\n\n\t\trows = append(rows, []string{\n\t\t\ts.SettledTime.Format(\"2006-01-02\"),\n\t\t\ts.Ticker,\n\t\t\tstrings.ToUpper(s.MarketResult),\n\t\t\trevenueStr,\n\t\t\tstrconv.Itoa(s.YesCount),\n\t\t\tstrconv.Itoa(s.NoCount),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderSettlementsPlain(settlements []models.Settlement) {\n\tfor _, s := range settlements {\n\t\tui.PrintPlain(\"%s\\t%s\\t%s\\t%s\\t%d\\t%d\",\n\t\t\ts.SettledTime.Format(\"2006-01-02\"),\n\t\t\ts.Ticker,\n\t\t\ts.MarketResult,\n\t\t\tui.FormatPrice(s.Revenue),\n\t\t\ts.YesCount,\n\t\t\ts.NoCount,\n\t\t)\n\t}\n}\n\nfunc runSubaccountsList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tsubaccounts, err := client.GetSubaccounts(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get subaccounts: %w\", err)\n\t}\n\n\tif len(subaccounts.Subaccounts) == 0 {\n\t\tPrintWarning(\"No subaccounts found\")\n\t\treturn nil\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderSubaccountsTable(subaccounts.Subaccounts) },\n\t\tsubaccounts,\n\t\tfunc() { renderSubaccountsPlain(subaccounts.Subaccounts) },\n\t)\n}\n\nfunc renderSubaccountsTable(subaccounts []models.Subaccount) {\n\theaders := []string{\"ID\", \"Balance\", \"Available\"}\n\trows := make([][]string, 0, len(subaccounts))\n\n\tfor _, s := range subaccounts {\n\t\trows = append(rows, []string{\n\t\t\tstrconv.Itoa(s.SubaccountID),\n\t\t\tui.FormatPrice(s.Balance),\n\t\t\tui.FormatPrice(s.AvailableBalance),\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderSubaccountsPlain(subaccounts []models.Subaccount) {\n\tfor _, s := range subaccounts {\n\t\tui.PrintPlain(\"%d\\t%s\\t%s\",\n\t\t\ts.SubaccountID,\n\t\t\tui.FormatPrice(s.Balance),\n\t\t\tui.FormatPrice(s.AvailableBalance),\n\t\t)\n\t}\n}\n\nfunc runSubaccountsCreate(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tsubaccount, err := client.CreateSubaccount(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create subaccount: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\tPrintSuccess(fmt.Sprintf(\"Subaccount created successfully (ID: %d)\", subaccount.SubaccountID))\n\t\t},\n\t\tsubaccount,\n\t\tfunc() {\n\t\t\tui.PrintPlain(\"subaccount_id=%d\", subaccount.SubaccountID)\n\t\t},\n\t)\n}\n\nfunc runSubaccountsTransfer(cmd *cobra.Command, args []string) error {\n\tif transferAmount \u003c= 0 {\n\t\treturn fmt.Errorf(\"amount must be positive (in cents)\")\n\t}\n\n\tif !SkipConfirmation() {\n\t\tconfirmed, err := confirmTransfer(transferFrom, transferTo, transferAmount)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !confirmed {\n\t\t\tPrintWarning(\"Transfer cancelled\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\trequest := &models.TransferRequest{\n\t\tFromSubaccount: transferFrom,\n\t\tToSubaccount: transferTo,\n\t\tAmount: transferAmount,\n\t}\n\n\ttransfer, err := client.Transfer(ctx, *request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to transfer: %w\", err)\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() {\n\t\t\tPrintSuccess(fmt.Sprintf(\"Transfer complete: %s from subaccount %d to %d\",\n\t\t\t\tui.FormatPrice(transfer.Amount),\n\t\t\t\ttransfer.FromSubaccount,\n\t\t\t\ttransfer.ToSubaccount,\n\t\t\t))\n\t\t},\n\t\ttransfer,\n\t\tfunc() {\n\t\t\tui.PrintPlain(\"transfer_id=%s amount=%d from=%d to=%d\",\n\t\t\t\ttransfer.TransferID,\n\t\t\t\ttransfer.Amount,\n\t\t\t\ttransfer.FromSubaccount,\n\t\t\t\ttransfer.ToSubaccount,\n\t\t\t)\n\t\t},\n\t)\n}\n\nfunc confirmTransfer(from, to, amount int) (bool, error) {\n\tfmt.Printf(\"\\nTransfer Details:\\n\")\n\tfmt.Printf(\" From Subaccount: %d\\n\", from)\n\tfmt.Printf(\" To Subaccount: %d\\n\", to)\n\tfmt.Printf(\" Amount: %s\\n\\n\", ui.FormatPrice(amount))\n\tfmt.Print(\"Confirm transfer? [y/N]: \")\n\n\treader := bufio.NewReader(os.Stdin)\n\tresponse, err := reader.ReadString('\\n')\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to read confirmation: %w\", err)\n\t}\n\n\tresponse = strings.TrimSpace(strings.ToLower(response))\n\treturn response == \"y\" || response == \"yes\", nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":13048,"content_sha256":"a17655b266ec4514020ab5846fb6c3ed60c97f6c47b092df857a4f7a01c7f1ef"},{"filename":"internal/cmd/rfq.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar (\n\trfqStatus string\n\trfqMarket string\n\trfqQuantity int\n\tquotesListRFQID string\n\tquotesCreateRFQID string\n\tquotePrice int\n)\n\nvar rfqCmd = &cobra.Command{\n\tUse: \"rfq\",\n\tShort: \"Manage RFQs (Request for Quotes)\",\n\tLong: `Create, list, and manage RFQs for block trading on Kalshi.`,\n}\n\nvar rfqListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List RFQs\",\n\tLong: `List all RFQs, optionally filtered by status.`,\n\tExample: ` kalshi-cli rfq list\n kalshi-cli rfq list --status open`,\n\tRunE: runRFQList,\n}\n\nvar rfqGetCmd = &cobra.Command{\n\tUse: \"get \u003crfq-id>\",\n\tShort: \"Get RFQ details\",\n\tLong: `Get detailed information about a specific RFQ.`,\n\tArgs: cobra.ExactArgs(1),\n\tExample: ` kalshi-cli rfq get rfq_abc123`,\n\tRunE: runRFQGet,\n}\n\nvar rfqCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create a new RFQ\",\n\tLong: `Create a new Request for Quote for block trading.`,\n\tExample: ` kalshi-cli rfq create --market INXD-25FEB07 --qty 1000`,\n\tRunE: runRFQCreate,\n}\n\nvar rfqDeleteCmd = &cobra.Command{\n\tUse: \"delete \u003crfq-id>\",\n\tShort: \"Delete an RFQ\",\n\tLong: `Delete an existing RFQ by ID.`,\n\tArgs: cobra.ExactArgs(1),\n\tExample: ` kalshi-cli rfq delete rfq_abc123`,\n\tRunE: runRFQDelete,\n}\n\nvar quotesCmd = &cobra.Command{\n\tUse: \"quotes\",\n\tShort: \"Manage quotes on RFQs\",\n\tLong: `Create, list, accept, and confirm quotes on RFQs.`,\n}\n\nvar quotesListCmd = &cobra.Command{\n\tUse: \"list\",\n\tShort: \"List quotes\",\n\tLong: `List all quotes, optionally filtered by RFQ ID.`,\n\tExample: ` kalshi-cli quotes list\n kalshi-cli quotes list --rfq-id rfq_abc123`,\n\tRunE: runQuotesList,\n}\n\nvar quotesCreateCmd = &cobra.Command{\n\tUse: \"create\",\n\tShort: \"Create a quote on an RFQ\",\n\tLong: `Create a new quote on an existing RFQ.`,\n\tExample: ` kalshi-cli quotes create --rfq rfq_abc123 --price 65`,\n\tRunE: runQuotesCreate,\n}\n\nvar quotesAcceptCmd = &cobra.Command{\n\tUse: \"accept \u003cquote-id>\",\n\tShort: \"Accept a quote\",\n\tLong: `Accept a quote that was offered on your RFQ.`,\n\tArgs: cobra.ExactArgs(1),\n\tExample: ` kalshi-cli quotes accept quote_xyz789`,\n\tRunE: runQuotesAccept,\n}\n\nvar quotesConfirmCmd = &cobra.Command{\n\tUse: \"confirm \u003cquote-id>\",\n\tShort: \"Confirm a quote\",\n\tLong: `Confirm a quote after it has been accepted.`,\n\tArgs: cobra.ExactArgs(1),\n\tExample: ` kalshi-cli quotes confirm quote_xyz789`,\n\tRunE: runQuotesConfirm,\n}\n\nfunc init() {\n\t// RFQ list flags\n\trfqListCmd.Flags().StringVar(&rfqStatus, \"status\", \"\", \"Filter by status (e.g., open, closed)\")\n\n\t// RFQ create flags\n\trfqCreateCmd.Flags().StringVar(&rfqMarket, \"market\", \"\", \"Market ticker (required)\")\n\trfqCreateCmd.Flags().IntVar(&rfqQuantity, \"qty\", 0, \"Quantity (required)\")\n\trfqCreateCmd.MarkFlagRequired(\"market\")\n\trfqCreateCmd.MarkFlagRequired(\"qty\")\n\n\t// Quotes list flags\n\tquotesListCmd.Flags().StringVar("esListRFQID, \"rfq-id\", \"\", \"Filter by RFQ ID\")\n\n\t// Quotes create flags\n\tquotesCreateCmd.Flags().StringVar("esCreateRFQID, \"rfq\", \"\", \"RFQ ID (required)\")\n\tquotesCreateCmd.Flags().IntVar("ePrice, \"price\", 0, \"Price in cents (required)\")\n\tquotesCreateCmd.MarkFlagRequired(\"rfq\")\n\tquotesCreateCmd.MarkFlagRequired(\"price\")\n\n\t// Add subcommands to rfq\n\trfqCmd.AddCommand(rfqListCmd)\n\trfqCmd.AddCommand(rfqGetCmd)\n\trfqCmd.AddCommand(rfqCreateCmd)\n\trfqCmd.AddCommand(rfqDeleteCmd)\n\n\t// Add subcommands to quotes\n\tquotesCmd.AddCommand(quotesListCmd)\n\tquotesCmd.AddCommand(quotesCreateCmd)\n\tquotesCmd.AddCommand(quotesAcceptCmd)\n\tquotesCmd.AddCommand(quotesConfirmCmd)\n\n\t// Register with root\n\trootCmd.AddCommand(rfqCmd)\n\trootCmd.AddCommand(quotesCmd)\n}\n\nfunc runRFQList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.GetRFQs(ctx, api.RFQsOptions{\n\t\tStatus: rfqStatus,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderRFQsTable(result.RFQs) },\n\t\tresult.RFQs,\n\t\tfunc() { renderRFQsPlain(result.RFQs) },\n\t)\n}\n\nfunc runRFQGet(cmd *cobra.Command, args []string) error {\n\trfqID := args[0]\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.GetRFQ(ctx, rfqID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderRFQDetail(&result.RFQ) },\n\t\tresult.RFQ,\n\t\tfunc() { renderRFQDetailPlain(&result.RFQ) },\n\t)\n}\n\nfunc runRFQCreate(cmd *cobra.Command, args []string) error {\n\tif rfqQuantity \u003c= 0 {\n\t\treturn fmt.Errorf(\"quantity must be greater than 0\")\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.CreateRFQ(ctx, models.CreateRFQRequest{\n\t\tMarketTicker: rfqMarket,\n\t\tContracts: rfqQuantity,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"RFQ created: %s\", result.RFQ.ID))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderRFQDetail(&result.RFQ) },\n\t\tresult.RFQ,\n\t\tfunc() { renderRFQDetailPlain(&result.RFQ) },\n\t)\n}\n\nfunc runRFQDelete(cmd *cobra.Command, args []string) error {\n\trfqID := args[0]\n\n\tif !confirmAction(fmt.Sprintf(\"Delete RFQ %s?\", rfqID)) {\n\t\tPrintWarning(\"Cancelled\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := client.CancelRFQ(ctx, rfqID); err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"RFQ %s deleted\", rfqID))\n\treturn nil\n}\n\nfunc runQuotesList(cmd *cobra.Command, args []string) error {\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.GetQuotes(ctx, api.QuotesOptions{\n\t\tRFQID: quotesListRFQID,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderQuotesTable(result.Quotes) },\n\t\tresult.Quotes,\n\t\tfunc() { renderQuotesPlain(result.Quotes) },\n\t)\n}\n\nfunc runQuotesCreate(cmd *cobra.Command, args []string) error {\n\tif quotePrice \u003c= 0 || quotePrice >= 100 {\n\t\treturn fmt.Errorf(\"price must be between 1 and 99 cents, got: %d\", quotePrice)\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.CreateQuote(ctx, models.CreateQuoteRequest{\n\t\tRFQID: quotesCreateRFQID,\n\t\tYesBid: quotePrice,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Quote created: %s\", result.Quote.ID))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderQuoteDetail(&result.Quote) },\n\t\tresult.Quote,\n\t\tfunc() { renderQuoteDetailPlain(&result.Quote) },\n\t)\n}\n\nfunc runQuotesAccept(cmd *cobra.Command, args []string) error {\n\tquoteID := args[0]\n\n\tif !confirmAction(fmt.Sprintf(\"Accept quote %s?\", quoteID)) {\n\t\tPrintWarning(\"Cancelled\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.AcceptQuote(ctx, quoteID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Quote %s accepted\", result.Quote.ID))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderQuoteDetail(&result.Quote) },\n\t\tresult.Quote,\n\t\tfunc() { renderQuoteDetailPlain(&result.Quote) },\n\t)\n}\n\nfunc runQuotesConfirm(cmd *cobra.Command, args []string) error {\n\tquoteID := args[0]\n\n\tif !confirmAction(fmt.Sprintf(\"Confirm quote %s?\", quoteID)) {\n\t\tPrintWarning(\"Cancelled\")\n\t\treturn nil\n\t}\n\n\tclient, err := createClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresult, err := client.ConfirmQuote(ctx, quoteID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tPrintSuccess(fmt.Sprintf(\"Quote %s confirmed\", result.Quote.ID))\n\n\treturn ui.Output(\n\t\tGetOutputFormat(),\n\t\tfunc() { renderQuoteDetail(&result.Quote) },\n\t\tresult.Quote,\n\t\tfunc() { renderQuoteDetailPlain(&result.Quote) },\n\t)\n}\n\n// RFQ rendering functions\n\nfunc renderRFQsTable(rfqs []models.RFQ) {\n\tif len(rfqs) == 0 {\n\t\tfmt.Println(\"No RFQs found\")\n\t\treturn\n\t}\n\n\theaders := []string{\"ID\", \"Market\", \"Contracts\", \"Status\", \"Created\"}\n\tvar rows [][]string\n\n\tfor _, rfq := range rfqs {\n\t\trows = append(rows, []string{\n\t\t\trfq.ID,\n\t\t\trfq.MarketTicker,\n\t\t\tfmt.Sprintf(\"%d\", rfq.Contracts),\n\t\t\tformatStatus(rfq.Status),\n\t\t\trfq.CreatedTs,\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderRFQsPlain(rfqs []models.RFQ) {\n\tfor _, rfq := range rfqs {\n\t\tfmt.Printf(\"%s\\t%s\\t%d\\t%s\\n\",\n\t\t\trfq.ID, rfq.MarketTicker, rfq.Contracts, rfq.Status)\n\t}\n}\n\nfunc renderRFQDetail(rfq *models.RFQ) {\n\tpairs := [][]string{\n\t\t{\"RFQ ID\", rfq.ID},\n\t\t{\"Market\", rfq.MarketTicker},\n\t\t{\"Contracts\", fmt.Sprintf(\"%d\", rfq.Contracts)},\n\t\t{\"Status\", formatStatus(rfq.Status)},\n\t\t{\"Created\", rfq.CreatedTs},\n\t}\n\tui.RenderKeyValue(pairs)\n}\n\nfunc renderRFQDetailPlain(rfq *models.RFQ) {\n\tfmt.Printf(\"rfq_id=%s market=%s qty=%d status=%s\\n\",\n\t\trfq.ID, rfq.MarketTicker, rfq.Contracts, rfq.Status)\n}\n\n// Quote rendering functions\n\nfunc renderQuotesTable(quotes []models.Quote) {\n\tif len(quotes) == 0 {\n\t\tfmt.Println(\"No quotes found\")\n\t\treturn\n\t}\n\n\theaders := []string{\"Quote ID\", \"RFQ ID\", \"Market\", \"Yes Bid\", \"No Bid\", \"Contracts\", \"Status\", \"Created\"}\n\tvar rows [][]string\n\n\tfor _, quote := range quotes {\n\t\trows = append(rows, []string{\n\t\t\tquote.ID,\n\t\t\tquote.RFQID,\n\t\t\tquote.MarketTicker,\n\t\t\tui.FormatPrice(quote.YesBid),\n\t\t\tui.FormatPrice(quote.NoBid),\n\t\t\tfmt.Sprintf(\"%d\", quote.Contracts),\n\t\t\tformatStatus(quote.Status),\n\t\t\tquote.CreatedTs,\n\t\t})\n\t}\n\n\tui.RenderTable(headers, rows)\n}\n\nfunc renderQuotesPlain(quotes []models.Quote) {\n\tfor _, quote := range quotes {\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%d\\t%d\\t%d\\t%s\\n\",\n\t\t\tquote.ID, quote.RFQID, quote.MarketTicker,\n\t\t\tquote.YesBid, quote.NoBid, quote.Contracts, quote.Status)\n\t}\n}\n\nfunc renderQuoteDetail(quote *models.Quote) {\n\tpairs := [][]string{\n\t\t{\"Quote ID\", quote.ID},\n\t\t{\"RFQ ID\", quote.RFQID},\n\t\t{\"Market\", quote.MarketTicker},\n\t\t{\"Yes Bid\", ui.FormatPrice(quote.YesBid)},\n\t\t{\"No Bid\", ui.FormatPrice(quote.NoBid)},\n\t\t{\"Contracts\", fmt.Sprintf(\"%d\", quote.Contracts)},\n\t\t{\"Status\", formatStatus(quote.Status)},\n\t\t{\"Created\", quote.CreatedTs},\n\t}\n\tui.RenderKeyValue(pairs)\n}\n\nfunc renderQuoteDetailPlain(quote *models.Quote) {\n\tfmt.Printf(\"quote_id=%s rfq_id=%s market=%s yes_bid=%d no_bid=%d qty=%d status=%s\\n\",\n\t\tquote.ID, quote.RFQID, quote.MarketTicker,\n\t\tquote.YesBid, quote.NoBid, quote.Contracts, quote.Status)\n}\n\n// Helper functions\n\nfunc formatStatus(status string) string {\n\tswitch strings.ToLower(status) {\n\tcase \"open\", \"active\":\n\t\treturn ui.StatusOpenStyle.Render(strings.ToUpper(status))\n\tcase \"closed\", \"expired\", \"cancelled\":\n\t\treturn ui.StatusClosedStyle.Render(strings.ToUpper(status))\n\tcase \"accepted\", \"confirmed\":\n\t\treturn ui.StatusActiveStyle.Render(strings.ToUpper(status))\n\tdefault:\n\t\treturn status\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":11385,"content_sha256":"8036234f49d54541fb719d0fdbe4272cd4323c736fb27f24d8460812cf8d396d"},{"filename":"internal/cmd/root.go","content":"package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n)\n\nvar (\n\tcfgFile string\n\tuseProd bool\n\tjsonOut bool\n\tplainOut bool\n\tyesFlag bool\n\tverbose bool\n\tcfg *config.Config\n\toutputFmt ui.OutputFormat\n\n\tbuildVersion = \"dev\"\n\tbuildCommit = \"none\"\n\tbuildDate = \"unknown\"\n)\n\nvar rootCmd = &cobra.Command{\n\tUse: \"kalshi-cli\",\n\tShort: \"CLI for the Kalshi prediction market exchange\",\n\tLong: `kalshi-cli is a comprehensive command-line interface for the Kalshi\nprediction market exchange. It provides access to all API endpoints,\nreal-time WebSocket streaming, and a first-class trading experience.\n\nBy default, commands use the demo API. Use --prod for production.`,\n\tPersistentPreRunE: func(cmd *cobra.Command, args []string) error {\n\t\treturn initConfig()\n\t},\n\tSilenceUsage: true,\n\tSilenceErrors: true,\n}\n\nfunc Execute() error {\n\treturn rootCmd.Execute()\n}\n\nfunc init() {\n\trootCmd.PersistentFlags().StringVar(&cfgFile, \"config\", \"\", \"config file (default is $HOME/.kalshi/config.yaml)\")\n\trootCmd.PersistentFlags().BoolVar(&useProd, \"prod\", false, \"use production API (default: demo)\")\n\trootCmd.PersistentFlags().BoolVar(&jsonOut, \"json\", false, \"output as JSON\")\n\trootCmd.PersistentFlags().BoolVar(&plainOut, \"plain\", false, \"output as plain text (for pipes)\")\n\trootCmd.PersistentFlags().BoolVarP(&yesFlag, \"yes\", \"y\", false, \"skip confirmation prompts\")\n\trootCmd.PersistentFlags().BoolVarP(&verbose, \"verbose\", \"v\", false, \"verbose output\")\n\n\tviper.BindPFlag(\"api.production\", rootCmd.PersistentFlags().Lookup(\"prod\"))\n\tviper.BindPFlag(\"output.json\", rootCmd.PersistentFlags().Lookup(\"json\"))\n\tviper.BindPFlag(\"output.plain\", rootCmd.PersistentFlags().Lookup(\"plain\"))\n\n\trootCmd.AddCommand(versionCmd)\n}\n\nfunc initConfig() error {\n\tvar err error\n\tcfg, err = config.Load(cfgFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to load config: %w\", err)\n\t}\n\n\tif useProd {\n\t\tcfg.API.Production = true\n\t}\n\n\tswitch {\n\tcase jsonOut:\n\t\toutputFmt = ui.FormatJSON\n\tcase plainOut:\n\t\toutputFmt = ui.FormatPlain\n\tdefault:\n\t\toutputFmt = ui.FormatTable\n\t}\n\n\treturn nil\n}\n\nfunc GetConfig() *config.Config {\n\treturn cfg\n}\n\nfunc GetOutputFormat() ui.OutputFormat {\n\treturn outputFmt\n}\n\nfunc IsVerbose() bool {\n\treturn verbose\n}\n\nfunc SkipConfirmation() bool {\n\treturn yesFlag\n}\n\nfunc PrintError(err error) {\n\tfmt.Fprintf(os.Stderr, \"%s %s\\n\", ui.ErrorStyle.Render(\"Error:\"), err.Error())\n}\n\nfunc PrintSuccess(msg string) {\n\tfmt.Println(ui.SuccessStyle.Render(msg))\n}\n\nfunc PrintWarning(msg string) {\n\tfmt.Println(ui.WarningStyle.Render(msg))\n}\n\n// SetVersionInfo is called from main to inject build-time variables.\nfunc SetVersionInfo(version, commit, date string) {\n\tbuildVersion = version\n\tbuildCommit = commit\n\tbuildDate = date\n\trootCmd.Version = version\n}\n\nvar versionCmd = &cobra.Command{\n\tUse: \"version\",\n\tShort: \"Print version information\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tfmt.Printf(\"kalshi-cli %s\\n\", buildVersion)\n\t\tfmt.Printf(\" commit: %s\\n\", buildCommit)\n\t\tfmt.Printf(\" built: %s\\n\", buildDate)\n\t},\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3175,"content_sha256":"a8cdf11cdcfa18c288712bd3406bc6b2e28a5e7f5a35d10b99cbd41cb9adab41"},{"filename":"internal/cmd/watch_test.go","content":"package cmd\n\nimport (\n\t\"testing\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/websocket\"\n)\n\nfunc TestRequiresAuth(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tchannels []websocket.Channel\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname: \"public channel market_ticker\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketTicker},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"public channel market_ticker_v2\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketTickerV2},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"public channel public_trades\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelPublicTrades},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"public channel market_lifecycle\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketLifecycle},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"public channel multivariate_lookups\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMultivariateLookups},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel orderbook\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelOrderbook},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel user_orders\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelUserOrders},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel user_fills\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelUserFills},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel market_positions\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketPositions},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel order_group_updates\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelOrderGroupUpdates},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth channel communications\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelCommunications},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed channels with one auth\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketTicker, websocket.ChannelUserOrders},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple public channels\",\n\t\t\tchannels: []websocket.Channel{websocket.ChannelMarketTicker, websocket.ChannelPublicTrades},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := requiresAuth(tt.channels)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"requiresAuth(%v) = %v, expected %v\", tt.channels, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestOrderbookRequiresAuth specifically tests the bug fix\n// where orderbook was not being flagged as requiring auth\nfunc TestOrderbookRequiresAuth(t *testing.T) {\n\tchannels := []websocket.Channel{websocket.ChannelOrderbook}\n\tif !requiresAuth(channels) {\n\t\tt.Error(\"orderbook channel should require authentication per Kalshi API spec\")\n\t}\n}\n\n// TestMarketPositionsChannelUsed verifies positions command uses correct channel\nfunc TestMarketPositionsChannelUsed(t *testing.T) {\n\tchannel := websocket.ChannelMarketPositions\n\tif channel != \"market_positions\" {\n\t\tt.Errorf(\"expected market_positions channel, got %s\", channel)\n\t}\n}\n\nfunc TestChannelNamesMatchKalshiAPI(t *testing.T) {\n\t// Document correct Kalshi WebSocket v2 channel names\n\ttests := []struct {\n\t\tname string\n\t\tchannel websocket.Channel\n\t\texpected string\n\t}{\n\t\t{\"ticker\", websocket.ChannelMarketTicker, \"ticker\"},\n\t\t{\"ticker_v2\", websocket.ChannelMarketTickerV2, \"ticker_v2\"},\n\t\t{\"orderbook_delta\", websocket.ChannelOrderbook, \"orderbook_delta\"},\n\t\t{\"trade\", websocket.ChannelPublicTrades, \"trade\"},\n\t\t{\"user_orders\", websocket.ChannelUserOrders, \"user_orders\"},\n\t\t{\"fill\", websocket.ChannelUserFills, \"fill\"},\n\t\t{\"market_positions\", websocket.ChannelMarketPositions, \"market_positions\"},\n\t\t{\"order_group_updates\", websocket.ChannelOrderGroupUpdates, \"order_group_updates\"},\n\t\t{\"communications\", websocket.ChannelCommunications, \"communications\"},\n\t\t{\"market_lifecycle_v2\", websocket.ChannelMarketLifecycle, \"market_lifecycle_v2\"},\n\t\t{\"multivariate\", websocket.ChannelMultivariateLookups, \"multivariate\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif string(tt.channel) != tt.expected {\n\t\t\t\tt.Errorf(\"channel %s = %q, want %q\", tt.name, tt.channel, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4110,"content_sha256":"14559fcc473a5649eacfe615900b8d9638b50bc8cf8144ace473b2c3ec2051e0"},{"filename":"internal/cmd/watch.go","content":"package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n\n\t\"github.com/6missedcalls/kalshi-cli/internal/api\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/config\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/ui\"\n\t\"github.com/6missedcalls/kalshi-cli/internal/websocket\"\n\t\"github.com/6missedcalls/kalshi-cli/pkg/models\"\n)\n\nvar (\n\twatchMarketFlag string\n)\n\nfunc init() {\n\trootCmd.AddCommand(watchCmd)\n\twatchCmd.AddCommand(watchTickerCmd)\n\twatchCmd.AddCommand(watchOrderbookCmd)\n\twatchCmd.AddCommand(watchTradesCmd)\n\twatchCmd.AddCommand(watchOrdersCmd)\n\twatchCmd.AddCommand(watchFillsCmd)\n\twatchCmd.AddCommand(watchPositionsCmd)\n\n\twatchTradesCmd.Flags().StringVar(&watchMarketFlag, \"market\", \"\", \"filter trades by market ticker\")\n}\n\nvar watchCmd = &cobra.Command{\n\tUse: \"watch\",\n\tShort: \"Watch live market data and account updates\",\n\tLong: `Stream real-time data from Kalshi via WebSocket.\n\nAll watch commands require authentication (API credentials).\nPress Ctrl+C to stop watching.\n\nAvailable streams:\n ticker Live price updates for a market (requires \u003cmarket-ticker>)\n orderbook Orderbook delta updates for a market (requires \u003cmarket-ticker>)\n trades Public trades feed (optional --market filter)\n orders Your order status changes\n fills Your fill notifications\n positions Your position changes`,\n\tExample: ` kalshi-cli watch ticker INXD-25FEB07-B5523.99\n kalshi-cli watch orderbook INXD-25FEB07-B5523.99\n kalshi-cli watch trades --market INXD-25FEB07-B5523.99\n kalshi-cli watch orders\n kalshi-cli watch fills --json\n kalshi-cli watch positions`,\n}\n\nvar watchTickerCmd = &cobra.Command{\n\tUse: \"ticker \u003cmarket-ticker>\",\n\tShort: \"Watch live price updates for a market\",\n\tLong: `Stream real-time price updates for a specific market.\n\nOutput includes bid/ask prices, volume, and open interest.\nUse 'kalshi-cli markets list' to find available market tickers.`,\n\tExample: ` kalshi-cli watch ticker INXD-25FEB07-B5523.99\n kalshi-cli watch ticker INXD-25FEB07-B5523.99 --json\n kalshi-cli watch ticker INXD-25FEB07-B5523.99 --plain`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runWatchTicker,\n}\n\nvar watchOrderbookCmd = &cobra.Command{\n\tUse: \"orderbook \u003cmarket-ticker>\",\n\tShort: \"Watch live orderbook updates for a market\",\n\tLong: `Stream real-time orderbook delta updates for a specific market.\n\nShows best bid/ask, depth, and orderbook changes as they occur.\nUse 'kalshi-cli markets list' to find available market tickers.`,\n\tExample: ` kalshi-cli watch orderbook INXD-25FEB07-B5523.99\n kalshi-cli watch orderbook INXD-25FEB07-B5523.99 --json`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runWatchOrderbook,\n}\n\nvar watchTradesCmd = &cobra.Command{\n\tUse: \"trades\",\n\tShort: \"Watch public trades feed\",\n\tLong: `Stream real-time public trades across all markets.\n\nOptionally filter to a single market using the --market flag.`,\n\tExample: ` kalshi-cli watch trades\n kalshi-cli watch trades --market INXD-25FEB07-B5523.99\n kalshi-cli watch trades --json`,\n\tRunE: runWatchTrades,\n}\n\nvar watchOrdersCmd = &cobra.Command{\n\tUse: \"orders\",\n\tShort: \"Watch your order updates\",\n\tLong: `Stream real-time updates for your orders.\n\nShows order status changes, fills, and cancellations as they happen.`,\n\tExample: ` kalshi-cli watch orders\n kalshi-cli watch orders --json`,\n\tRunE: runWatchOrders,\n}\n\nvar watchFillsCmd = &cobra.Command{\n\tUse: \"fills\",\n\tShort: \"Watch your fill notifications\",\n\tLong: `Stream real-time fill notifications for your orders.\n\nShows each individual fill as it occurs, including price, count, and taker/maker status.`,\n\tExample: ` kalshi-cli watch fills\n kalshi-cli watch fills --json`,\n\tRunE: runWatchFills,\n}\n\nvar watchPositionsCmd = &cobra.Command{\n\tUse: \"positions\",\n\tShort: \"Watch your position changes\",\n\tLong: `Stream real-time position updates.\n\nShows changes to your positions including realized PnL, exposure, and total cost.`,\n\tExample: ` kalshi-cli watch positions\n kalshi-cli watch positions --json`,\n\tRunE: runWatchPositions,\n}\n\nfunc runWatchTicker(_ *cobra.Command, args []string) error {\n\tticker := args[0]\n\tparams := map[string]string{\"market_tickers\": ticker}\n\treturn runWatch(websocket.ChannelMarketTicker, params)\n}\n\nfunc runWatchOrderbook(_ *cobra.Command, args []string) error {\n\tticker := args[0]\n\tparams := map[string]string{\"market_tickers\": ticker}\n\treturn runWatch(websocket.ChannelOrderbook, params)\n}\n\nfunc runWatchTrades(_ *cobra.Command, _ []string) error {\n\tparams := make(map[string]string)\n\tif watchMarketFlag != \"\" {\n\t\tparams[\"market_tickers\"] = watchMarketFlag\n\t}\n\treturn runWatch(websocket.ChannelPublicTrades, params)\n}\n\nfunc runWatchOrders(_ *cobra.Command, _ []string) error {\n\treturn runWatch(websocket.ChannelUserOrders, nil)\n}\n\nfunc runWatchFills(_ *cobra.Command, _ []string) error {\n\treturn runWatch(websocket.ChannelUserFills, nil)\n}\n\nfunc runWatchPositions(_ *cobra.Command, _ []string) error {\n\treturn runWatch(websocket.ChannelMarketPositions, nil)\n}\n\nfunc runWatch(channel websocket.Channel, params map[string]string) error {\n\treturn runWatchMultiple([]websocket.Channel{channel}, params)\n}\n\nfunc runWatchMultiple(channels []websocket.Channel, params map[string]string) error {\n\tcfg := GetConfig()\n\n\topts, err := buildClientOptions(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)\n\n\tgo func() {\n\t\t\u003c-sigChan\n\t\tif IsVerbose() {\n\t\t\tfmt.Fprintln(os.Stderr, \"\\nShutting down...\")\n\t\t}\n\t\tcancel()\n\t}()\n\n\tclient := websocket.NewClient(opts)\n\n\tclient.OnReconnect(func() {\n\t\tif IsVerbose() {\n\t\t\tfmt.Fprintln(os.Stderr, \"Reconnected\")\n\t\t}\n\t})\n\n\tclient.OnError(func(err error) {\n\t\tif IsVerbose() {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t}\n\t})\n\n\tregisterHandlers(client, channels)\n\n\tif err := client.Connect(ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to connect: %w\", err)\n\t}\n\tdefer client.Close()\n\n\tif IsVerbose() {\n\t\tfmt.Fprintf(os.Stderr, \"Connected to %s\\n\", cfg.Environment())\n\t}\n\n\tfor _, ch := range channels {\n\t\tif err := client.Subscribe(ctx, ch, params); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to subscribe to %s: %w\", ch, err)\n\t\t}\n\t}\n\n\tif IsVerbose() {\n\t\tchannelNames := make([]string, len(channels))\n\t\tfor i, ch := range channels {\n\t\t\tchannelNames[i] = string(ch)\n\t\t}\n\t\tfmt.Fprintf(os.Stderr, \"Subscribed to: %s\\n\", strings.Join(channelNames, \", \"))\n\t}\n\n\t\u003c-ctx.Done()\n\treturn nil\n}\n\nfunc buildClientOptions(cfg *config.Config) (websocket.ClientOptions, error) {\n\topts := websocket.ClientOptions{\n\t\tURL: cfg.WebSocketURL(),\n\t}\n\n\tsigner, err := getSigner(cfg)\n\tif err != nil {\n\t\treturn opts, fmt.Errorf(\"authentication required for WebSocket connection: %w\", err)\n\t}\n\n\ttimestamp := time.Now().UTC()\n\tsignature, err := signer.Sign(timestamp, \"GET\", \"/trade-api/ws/v2\")\n\tif err != nil {\n\t\treturn opts, fmt.Errorf(\"failed to sign request: %w\", err)\n\t}\n\n\topts.APIKeyID = signer.APIKeyID()\n\topts.Signature = signature\n\topts.Timestamp = api.TimestampHeader(timestamp)\n\n\treturn opts, nil\n}\n\nfunc registerHandlers(client *websocket.Client, channels []websocket.Channel) {\n\toutputFormat := GetOutputFormat()\n\n\tfor _, ch := range channels {\n\t\tswitch ch {\n\t\tcase websocket.ChannelMarketTicker:\n\t\t\tclient.RegisterHandler(ch, &tickerHandler{format: outputFormat})\n\t\tcase websocket.ChannelMarketTickerV2:\n\t\t\tclient.RegisterHandler(ch, &tickerV2Handler{format: outputFormat})\n\t\tcase websocket.ChannelOrderbook:\n\t\t\tclient.RegisterHandler(ch, &orderbookHandler{format: outputFormat})\n\t\tcase websocket.ChannelPublicTrades:\n\t\t\tclient.RegisterHandler(ch, &tradesHandler{format: outputFormat, filterTicker: watchMarketFlag})\n\t\tcase websocket.ChannelUserOrders:\n\t\t\tclient.RegisterHandler(ch, &ordersHandler{format: outputFormat})\n\t\tcase websocket.ChannelUserFills:\n\t\t\tclient.RegisterHandler(ch, &fillsHandler{format: outputFormat})\n\t\tcase websocket.ChannelMarketPositions:\n\t\t\tclient.RegisterHandler(ch, &positionsHandler{format: outputFormat})\n\t\tcase websocket.ChannelMarketLifecycle:\n\t\t\tclient.RegisterHandler(ch, &lifecycleHandler{format: outputFormat})\n\t\tcase websocket.ChannelOrderGroupUpdates:\n\t\t\tclient.RegisterHandler(ch, &orderGroupHandler{format: outputFormat})\n\t\tcase websocket.ChannelCommunications:\n\t\t\tclient.RegisterHandler(ch, &communicationsHandler{format: outputFormat})\n\t\t}\n\t}\n}\n\nfunc requiresAuth(channels []websocket.Channel) bool {\n\tfor _, ch := range channels {\n\t\tif websocket.ChannelRequiresAuth(ch) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc getSigner(_ *config.Config) (*api.Signer, error) {\n\t// Try config file first (fast, no GUI prompts)\n\tapiKeyID := viper.GetString(\"api_key_id\")\n\tprivateKeyPath := viper.GetString(\"private_key_path\")\n\n\t// Check env vars\n\tif apiKeyID == \"\" {\n\t\tapiKeyID = os.Getenv(\"KALSHI_API_KEY_ID\")\n\t}\n\tif privateKeyPath == \"\" {\n\t\tprivateKeyPath = os.Getenv(\"KALSHI_PRIVATE_KEY_FILE\")\n\t}\n\n\tif apiKeyID != \"\" && privateKeyPath != \"\" {\n\t\tpemData, err := os.ReadFile(privateKeyPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read private key file: %w\", err)\n\t\t}\n\t\treturn api.NewSignerFromPEM(apiKeyID, string(pemData))\n\t}\n\n\t// Check KALSHI_PRIVATE_KEY env var (PEM content directly)\n\tprivateKeyPEM := os.Getenv(\"KALSHI_PRIVATE_KEY\")\n\tif apiKeyID != \"\" && privateKeyPEM != \"\" {\n\t\treturn api.NewSignerFromPEM(apiKeyID, privateKeyPEM)\n\t}\n\n\t// Last resort: keyring\n\tkeyringStore, err := config.NewKeyringStore()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to access keyring: %w\", err)\n\t}\n\tcreds, err := keyringStore.GetCredentials()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\tif creds == nil {\n\t\treturn nil, fmt.Errorf(\"no credentials configured\")\n\t}\n\treturn api.NewSignerFromPEM(creds.APIKeyID, creds.PrivateKey)\n}\n\nfunc formatTimestamp() string {\n\treturn time.Now().Format(\"15:04:05\")\n}\n\n\nfunc formatVolume(vol int) string {\n\tif vol >= 1000000 {\n\t\treturn fmt.Sprintf(\"%.1fM\", float64(vol)/1000000)\n\t}\n\tif vol >= 1000 {\n\t\treturn fmt.Sprintf(\"%.1fK\", float64(vol)/1000)\n\t}\n\treturn fmt.Sprintf(\"%d\", vol)\n}\n\n// tickerHandler handles market ticker messages\ntype tickerHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *tickerHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.TickerData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse ticker data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *tickerHandler) output(data websocket.TickerData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s %s yes=%d no=%d vol=%d oi=%d\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.YesPrice, data.NoPrice, data.Volume, data.OpenInterest)\n\tdefault:\n\t\tspread := \"\"\n\t\tif data.YesBid > 0 && data.YesAsk > 0 {\n\t\t\tspread = fmt.Sprintf(\"Yes %s / %s\", formatCents(data.YesBid), formatCents(data.YesAsk))\n\t\t} else {\n\t\t\tspread = fmt.Sprintf(\"Yes %s\", formatCents(data.YesPrice))\n\t\t}\n\t\tfmt.Printf(\"[%s] %s: %s | Vol: %s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, spread, formatVolume(data.Volume))\n\t}\n\treturn nil\n}\n\n// orderbookHandler handles orderbook messages\ntype orderbookHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *orderbookHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.OrderbookData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse orderbook data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *orderbookHandler) output(data websocket.OrderbookData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tbids := formatLevels(data.YesBids, 3)\n\t\tasks := formatLevels(data.YesAsks, 3)\n\t\tfmt.Printf(\"%s %s bids=[%s] asks=[%s]\\n\",\n\t\t\tformatTimestamp(), data.Ticker, bids, asks)\n\tdefault:\n\t\tbestBid := \"-\"\n\t\tbestAsk := \"-\"\n\t\tbidDepth := 0\n\t\taskDepth := 0\n\n\t\tif len(data.YesBids) > 0 {\n\t\t\tbestBid = formatCents(data.YesBids[0].Price)\n\t\t\tfor _, l := range data.YesBids {\n\t\t\t\tbidDepth += l.Quantity\n\t\t\t}\n\t\t}\n\t\tif len(data.YesAsks) > 0 {\n\t\t\tbestAsk = formatCents(data.YesAsks[0].Price)\n\t\t\tfor _, l := range data.YesAsks {\n\t\t\t\taskDepth += l.Quantity\n\t\t\t}\n\t\t}\n\n\t\tfmt.Printf(\"[%s] %s: Bid %s (%d) | Ask %s (%d)\\n\",\n\t\t\tformatTimestamp(), data.Ticker, bestBid, bidDepth, bestAsk, askDepth)\n\t}\n\treturn nil\n}\n\nfunc formatLevels(levels []websocket.OrderbookLevel, max int) string {\n\tif len(levels) == 0 {\n\t\treturn \"-\"\n\t}\n\n\tcount := len(levels)\n\tif count > max {\n\t\tcount = max\n\t}\n\n\tparts := make([]string, count)\n\tfor i := 0; i \u003c count; i++ {\n\t\tparts[i] = fmt.Sprintf(\"%d@%d\", levels[i].Quantity, levels[i].Price)\n\t}\n\treturn strings.Join(parts, \",\")\n}\n\n// tradesHandler handles public trades messages\ntype tradesHandler struct {\n\tformat ui.OutputFormat\n\tfilterTicker string\n}\n\nfunc (h *tradesHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.TradeData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse trade data: %w\", err)\n\t}\n\n\tif h.filterTicker != \"\" && data.Ticker != h.filterTicker {\n\t\treturn nil\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *tradesHandler) output(data websocket.TradeData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s %s %s price=%d count=%d\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.TakerSide, data.Price, data.Count)\n\tdefault:\n\t\tside := data.TakerSide\n\t\tif side == \"yes\" {\n\t\t\tside = ui.PriceUpStyle.Render(\"BUY\")\n\t\t} else {\n\t\t\tside = ui.PriceDownStyle.Render(\"SELL\")\n\t\t}\n\t\tfmt.Printf(\"[%s] %s: %s %d @ %s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, side, data.Count, formatCents(data.Price))\n\t}\n\treturn nil\n}\n\n// ordersHandler handles user order messages\ntype ordersHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *ordersHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.OrderUpdateData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse order data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *ordersHandler) output(data websocket.OrderUpdateData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\torderID := truncateID(data.OrderID, 8)\n\t\tfmt.Printf(\"%s order=%s ticker=%s status=%s side=%s action=%s qty=%d/%d\\n\",\n\t\t\tformatTimestamp(), orderID, data.Ticker, data.Status,\n\t\t\tdata.Side, data.Action, data.FilledQuantity, data.InitialQuantity)\n\tdefault:\n\t\torderID := truncateID(data.OrderID, 8)\n\t\tstatus := formatOrderStatus(data.Status)\n\t\tprice := data.YesPrice\n\t\tif data.Side == \"no\" {\n\t\t\tprice = data.NoPrice\n\t\t}\n\t\tfmt.Printf(\"[%s] Order %s: %s %s %s @ %s | %s (%d/%d filled)\\n\",\n\t\t\tformatTimestamp(), orderID, strings.ToUpper(data.Action),\n\t\t\tdata.Ticker, strings.ToUpper(data.Side), formatCents(price),\n\t\t\tstatus, data.FilledQuantity, data.InitialQuantity)\n\t}\n\treturn nil\n}\n\nfunc formatOrderStatus(status string) string {\n\tswitch status {\n\tcase string(models.OrderStatusResting):\n\t\treturn ui.StatusActiveStyle.Render(\"RESTING\")\n\tcase string(models.OrderStatusExecuted):\n\t\treturn ui.SuccessStyle.Render(\"EXECUTED\")\n\tcase string(models.OrderStatusCanceled):\n\t\treturn ui.MutedStyle.Render(\"CANCELED\")\n\tcase string(models.OrderStatusPending):\n\t\treturn ui.WarningStyle.Render(\"PENDING\")\n\tdefault:\n\t\treturn status\n\t}\n}\n\n// fillsHandler handles user fill messages\ntype fillsHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *fillsHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.FillData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse fill data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *fillsHandler) output(data websocket.FillData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfillID := truncateID(data.FillID, 8)\n\t\torderID := truncateID(data.OrderID, 8)\n\t\tfmt.Printf(\"%s fill=%s order=%s ticker=%s side=%s action=%s price=%d count=%d taker=%v\\n\",\n\t\t\tformatTimestamp(), fillID, orderID, data.Ticker,\n\t\t\tdata.Side, data.Action, data.YesPrice, data.Count, data.IsTaker)\n\tdefault:\n\t\ttakerMaker := \"maker\"\n\t\tif data.IsTaker {\n\t\t\ttakerMaker = \"taker\"\n\t\t}\n\t\tprice := data.YesPrice\n\t\tif data.Side == \"no\" {\n\t\t\tprice = data.NoPrice\n\t\t}\n\t\tfmt.Printf(\"[%s] FILL: %s %s %s @ %s x%d (%s)\\n\",\n\t\t\tformatTimestamp(), strings.ToUpper(data.Action), data.Ticker,\n\t\t\tstrings.ToUpper(data.Side), formatCents(price), data.Count, takerMaker)\n\t}\n\treturn nil\n}\n\nfunc truncateID(id string, length int) string {\n\tif len(id) \u003c= length {\n\t\treturn id\n\t}\n\treturn id[:length]\n}\n\nfunc printJSONLine(v interface{}) error {\n\tdata, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal JSON: %w\", err)\n\t}\n\tfmt.Println(string(data))\n\treturn nil\n}\n\n// tickerV2Handler handles market_ticker_v2 incremental delta messages\ntype tickerV2Handler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *tickerV2Handler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.TickerV2Data\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse ticker v2 data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *tickerV2Handler) output(data websocket.TickerV2Data) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s %s delta_type=%s yes=%d no=%d delta=%d\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.DeltaType, data.YesPrice, data.NoPrice, data.Delta)\n\tdefault:\n\t\tfmt.Printf(\"[%s] %s: %s (delta: %+d) Yes %s / No %s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.DeltaType, data.Delta,\n\t\t\tformatCents(data.YesPrice), formatCents(data.NoPrice))\n\t}\n\treturn nil\n}\n\n// positionsHandler handles market_positions messages\ntype positionsHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *positionsHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.PositionData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse position data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *positionsHandler) output(data websocket.PositionData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s ticker=%s position=%d cost=%d pnl=%d exposure=%d\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.Position, data.TotalCost, data.RealizedPnl, data.Exposure)\n\tdefault:\n\t\tpnlStyle := ui.MutedStyle\n\t\tif data.RealizedPnl > 0 {\n\t\t\tpnlStyle = ui.PriceUpStyle\n\t\t} else if data.RealizedPnl \u003c 0 {\n\t\t\tpnlStyle = ui.PriceDownStyle\n\t\t}\n\t\tfmt.Printf(\"[%s] %s: Position %d | Cost %s | PnL %s | Exposure %s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.Position,\n\t\t\tformatCents(data.TotalCost), pnlStyle.Render(formatCents(data.RealizedPnl)),\n\t\t\tformatCents(data.Exposure))\n\t}\n\treturn nil\n}\n\n// lifecycleHandler handles market_lifecycle messages\ntype lifecycleHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *lifecycleHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.MarketLifecycleData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse lifecycle data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *lifecycleHandler) output(data websocket.MarketLifecycleData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s ticker=%s status=%s old_status=%s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.Status, data.OldStatus)\n\tdefault:\n\t\tfmt.Printf(\"[%s] %s: %s -> %s\\n\",\n\t\t\tformatTimestamp(), data.Ticker, data.OldStatus, data.Status)\n\t}\n\treturn nil\n}\n\n// orderGroupHandler handles order_group_updates messages\ntype orderGroupHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *orderGroupHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.OrderGroupUpdateData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse order group data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *orderGroupHandler) output(data websocket.OrderGroupUpdateData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s order_group=%s status=%s total=%d filled=%d\\n\",\n\t\t\tformatTimestamp(), data.OrderGroupID, data.Status, data.TotalOrders, data.FilledOrders)\n\tdefault:\n\t\tfmt.Printf(\"[%s] Order Group %s: %s (%d/%d filled)\\n\",\n\t\t\tformatTimestamp(), truncateID(data.OrderGroupID, 8), data.Status,\n\t\t\tdata.FilledOrders, data.TotalOrders)\n\t}\n\treturn nil\n}\n\n// communicationsHandler handles communications (RFQ/quote) messages\ntype communicationsHandler struct {\n\tformat ui.OutputFormat\n}\n\nfunc (h *communicationsHandler) HandleMessage(msg websocket.Message) error {\n\tvar data websocket.CommunicationData\n\tif err := json.Unmarshal(msg.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse communication data: %w\", err)\n\t}\n\n\treturn h.output(data)\n}\n\nfunc (h *communicationsHandler) output(data websocket.CommunicationData) error {\n\tswitch h.format {\n\tcase ui.FormatJSON:\n\t\treturn printJSONLine(data)\n\tcase ui.FormatPlain:\n\t\tfmt.Printf(\"%s type=%s ticker=%s qty=%d price=%d side=%s\\n\",\n\t\t\tformatTimestamp(), data.Type, data.Ticker, data.Quantity, data.Price, data.Side)\n\tdefault:\n\t\tfmt.Printf(\"[%s] %s: %s %s %d @ %s\\n\",\n\t\t\tformatTimestamp(), strings.ToUpper(data.Type), data.Ticker,\n\t\t\tstrings.ToUpper(data.Side), data.Quantity, formatCents(data.Price))\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":21466,"content_sha256":"b3c7a7350a6e962ea30269fe7384140854dfaf814f3a0c863d77119e46dc44dd"},{"filename":"internal/config/config.go","content":"package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/spf13/viper\"\n)\n\nconst (\n\t// Base URLs WITHOUT the /trade-api/v2 prefix (added by API methods)\n\tDemoBaseURL = \"https://demo-api.kalshi.co\"\n\tProdBaseURL = \"https://api.elections.kalshi.com\"\n\n\t// WebSocket URLs (these need the full path)\n\tDemoWSURL = \"wss://demo-api.kalshi.co/trade-api/ws/v2\"\n\tProdWSURL = \"wss://api.elections.kalshi.com/trade-api/ws/v2\"\n)\n\ntype Config struct {\n\tAPI APIConfig `mapstructure:\"api\"`\n\tOutput OutputConfig `mapstructure:\"output\"`\n\tDefaults DefaultsConfig `mapstructure:\"defaults\"`\n}\n\ntype APIConfig struct {\n\tProduction bool `mapstructure:\"production\"`\n\tTimeout time.Duration `mapstructure:\"timeout\"`\n}\n\ntype OutputConfig struct {\n\tFormat string `mapstructure:\"format\"`\n\tColor bool `mapstructure:\"color\"`\n}\n\ntype DefaultsConfig struct {\n\tLimit int `mapstructure:\"limit\"`\n}\n\nfunc (c *Config) BaseURL() string {\n\tif c.API.Production {\n\t\treturn ProdBaseURL\n\t}\n\treturn DemoBaseURL\n}\n\nfunc (c *Config) WebSocketURL() string {\n\tif c.API.Production {\n\t\treturn ProdWSURL\n\t}\n\treturn DemoWSURL\n}\n\nfunc (c *Config) Environment() string {\n\tif c.API.Production {\n\t\treturn \"production\"\n\t}\n\treturn \"demo\"\n}\n\nfunc Load(cfgFile string) (*Config, error) {\n\tif cfgFile != \"\" {\n\t\tviper.SetConfigFile(cfgFile)\n\t} else {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get home directory: %w\", err)\n\t\t}\n\n\t\tconfigDir := filepath.Join(home, \".kalshi\")\n\t\tif err := os.MkdirAll(configDir, 0700); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create config directory: %w\", err)\n\t\t}\n\n\t\tviper.AddConfigPath(configDir)\n\t\tviper.SetConfigName(\"config\")\n\t\tviper.SetConfigType(\"yaml\")\n\t}\n\n\tsetDefaults()\n\n\tviper.AutomaticEnv()\n\tviper.SetEnvPrefix(\"KALSHI\")\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\treturn nil, fmt.Errorf(\"failed to read config: %w\", err)\n\t\t}\n\t}\n\n\tvar cfg Config\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse config: %w\", err)\n\t}\n\n\treturn &cfg, nil\n}\n\nfunc setDefaults() {\n\tviper.SetDefault(\"api.production\", false)\n\tviper.SetDefault(\"api.timeout\", 30*time.Second)\n\tviper.SetDefault(\"output.format\", \"table\")\n\tviper.SetDefault(\"output.color\", true)\n\tviper.SetDefault(\"defaults.limit\", 50)\n}\n\nfunc Save(cfg *Config) error {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get home directory: %w\", err)\n\t}\n\n\tconfigPath := filepath.Join(home, \".kalshi\", \"config.yaml\")\n\n\tviper.Set(\"api.production\", cfg.API.Production)\n\tviper.Set(\"api.timeout\", cfg.API.Timeout)\n\tviper.Set(\"output.format\", cfg.Output.Format)\n\tviper.Set(\"output.color\", cfg.Output.Color)\n\tviper.Set(\"defaults.limit\", cfg.Defaults.Limit)\n\n\treturn viper.WriteConfigAs(configPath)\n}\n\nfunc ConfigDir() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, \".kalshi\"), nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2988,"content_sha256":"12931a1d0e5e274d018847e70c606edddb5adf6126e7ca95ca72a2d7e1ad91b9"},{"filename":"internal/config/keyring.go","content":"package config\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/99designs/keyring\"\n)\n\nconst (\n\tserviceName = \"kalshi-cli\"\n\tcredsKey = \"credentials\"\n)\n\ntype Credentials struct {\n\tAPIKeyID string `json:\"api_key_id\"`\n\tPrivateKey string `json:\"private_key\"`\n}\n\ntype KeyringStore struct {\n\tring keyring.Keyring\n}\n\nfunc NewKeyringStore() (*KeyringStore, error) {\n\tring, err := keyring.Open(keyring.Config{\n\t\tServiceName: serviceName,\n\t\tKeychainTrustApplication: true,\n\t\tKeychainAccessibleWhenUnlocked: true,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open keyring: %w\", err)\n\t}\n\n\treturn &KeyringStore{ring: ring}, nil\n}\n\nfunc (k *KeyringStore) SaveCredentials(creds Credentials) error {\n\tdata, err := json.Marshal(creds)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal credentials: %w\", err)\n\t}\n\n\terr = k.ring.Set(keyring.Item{\n\t\tKey: credsKey,\n\t\tData: data,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (k *KeyringStore) GetCredentials() (*Credentials, error) {\n\titem, err := k.ring.Get(credsKey)\n\tif err != nil {\n\t\tif err == keyring.ErrKeyNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\n\tvar creds Credentials\n\tif err := json.Unmarshal(item.Data, &creds); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse credentials: %w\", err)\n\t}\n\n\treturn &creds, nil\n}\n\nfunc (k *KeyringStore) DeleteCredentials() error {\n\terr := k.ring.Remove(credsKey)\n\tif err != nil && err != keyring.ErrKeyNotFound {\n\t\treturn fmt.Errorf(\"failed to delete credentials: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (k *KeyringStore) HasCredentials() bool {\n\t_, err := k.ring.Get(credsKey)\n\treturn err == nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1708,"content_sha256":"749ae47e2fe0e15fe2d7da67c34a53e3f6a664798a389fffb1056b1ae05cd405"},{"filename":"internal/ui/chart_test.go","content":"package ui\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc captureOutput(fn func()) string {\n\told := os.Stdout\n\tr, w, _ := os.Pipe()\n\tos.Stdout = w\n\n\tfn()\n\n\tw.Close()\n\tos.Stdout = old\n\n\tvar buf bytes.Buffer\n\tio.Copy(&buf, r)\n\treturn buf.String()\n}\n\nfunc TestRenderCandlestickChart_Empty(t *testing.T) {\n\tout := captureOutput(func() {\n\t\tRenderCandlestickChart(nil, \"Test\")\n\t})\n\n\tif !strings.Contains(out, \"No candlestick data\") {\n\t\tt.Errorf(\"expected 'No candlestick data' message, got: %s\", out)\n\t}\n}\n\nfunc TestRenderCandlestickChart_SingleCandle(t *testing.T) {\n\tcandles := []CandleData{\n\t\t{Label: \"12:00\", Open: 50, High: 60, Low: 40, Close: 55, Volume: 100},\n\t}\n\n\tout := captureOutput(func() {\n\t\tRenderCandlestickChart(candles, \"Single\")\n\t})\n\n\tif out == \"\" {\n\t\tt.Fatal(\"expected output, got empty string\")\n\t}\n\tif !strings.Contains(out, \"Single\") {\n\t\tt.Error(\"expected title in output\")\n\t}\n\tif !strings.Contains(out, \"$0.55\") {\n\t\tt.Errorf(\"expected last close $0.55 in output, got: %s\", out)\n\t}\n}\n\nfunc TestRenderCandlestickChart_MultipleCandles(t *testing.T) {\n\tcandles := []CandleData{\n\t\t{Label: \"10:00\", Open: 40, High: 50, Low: 35, Close: 45, Volume: 200},\n\t\t{Label: \"10:15\", Open: 45, High: 52, Low: 42, Close: 48, Volume: 180},\n\t\t{Label: \"10:30\", Open: 48, High: 55, Low: 44, Close: 52, Volume: 250},\n\t\t{Label: \"10:45\", Open: 52, High: 58, Low: 48, Close: 55, Volume: 310},\n\t\t{Label: \"11:00\", Open: 55, High: 60, Low: 50, Close: 53, Volume: 280},\n\t\t{Label: \"11:15\", Open: 53, High: 57, Low: 47, Close: 50, Volume: 220},\n\t\t{Label: \"11:30\", Open: 50, High: 54, Low: 45, Close: 48, Volume: 190},\n\t\t{Label: \"11:45\", Open: 48, High: 56, Low: 46, Close: 54, Volume: 340},\n\t\t{Label: \"12:00\", Open: 54, High: 62, Low: 52, Close: 60, Volume: 400},\n\t\t{Label: \"12:15\", Open: 60, High: 65, Low: 55, Close: 58, Volume: 150},\n\t}\n\n\tout := captureOutput(func() {\n\t\tRenderCandlestickChart(candles, \"Multi\")\n\t})\n\n\tif !strings.Contains(out, \"Multi\") {\n\t\tt.Error(\"expected title in output\")\n\t}\n\tif !strings.Contains(out, \"$\") {\n\t\tt.Error(\"expected dollar-formatted price labels\")\n\t}\n\tif !strings.Contains(out, \"Vol\") {\n\t\tt.Error(\"expected volume sparkline row\")\n\t}\n\tif !strings.Contains(out, \"10:00\") {\n\t\tt.Error(\"expected first x-axis label\")\n\t}\n\tif !strings.Contains(out, \"12:15\") {\n\t\tt.Error(\"expected last x-axis label\")\n\t}\n}\n\nfunc TestRenderCandlestickChart_FlatCandle(t *testing.T) {\n\tcandles := []CandleData{\n\t\t{Label: \"09:00\", Open: 50, High: 50, Low: 50, Close: 50, Volume: 10},\n\t}\n\n\tout := captureOutput(func() {\n\t\tRenderCandlestickChart(candles, \"Flat\")\n\t})\n\n\tif out == \"\" {\n\t\tt.Fatal(\"expected output for flat candle\")\n\t}\n}\n\nfunc TestPriceToRow(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tprice int\n\t\tmin, max int\n\t\twantRow int\n\t}{\n\t\t{\"at max\", 100, 0, 100, 0},\n\t\t{\"at min\", 0, 0, 100, chartHeight - 1},\n\t\t{\"midpoint\", 50, 0, 100, chartHeight / 2},\n\t\t{\"above max clamps\", 110, 0, 100, 0},\n\t\t{\"below min clamps\", -5, 0, 100, chartHeight - 1},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := priceToRow(tt.price, tt.min, tt.max)\n\t\t\tif got != tt.wantRow {\n\t\t\t\tt.Errorf(\"priceToRow(%d, %d, %d) = %d, want %d\", tt.price, tt.min, tt.max, got, tt.wantRow)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRowToPrice(t *testing.T) {\n\t// Top row should be max price\n\tgot := rowToPrice(0, 0, 100)\n\tif got != 100 {\n\t\tt.Errorf(\"rowToPrice(0, 0, 100) = %d, want 100\", got)\n\t}\n\n\t// Bottom row should be min price\n\tgot = rowToPrice(chartHeight-1, 0, 100)\n\tif got != 0 {\n\t\tt.Errorf(\"rowToPrice(%d, 0, 100) = %d, want 0\", chartHeight-1, got)\n\t}\n}\n\nfunc TestVolumeBar(t *testing.T) {\n\ttests := []struct {\n\t\tvol, maxVol int\n\t\twant rune\n\t}{\n\t\t{0, 100, '▁'},\n\t\t{100, 100, '█'},\n\t\t{0, 0, '▁'},\n\t\t{50, 100, '▄'},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := volumeBar(tt.vol, tt.maxVol)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"volumeBar(%d, %d) = %c, want %c\", tt.vol, tt.maxVol, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestPriceBounds(t *testing.T) {\n\tcandles := []CandleData{\n\t\t{Low: 30, High: 50},\n\t\t{Low: 20, High: 60},\n\t\t{Low: 25, High: 55},\n\t}\n\n\tlo, hi := priceBounds(candles)\n\t// min Low = 20, padding -1 = 19\n\tif lo != 19 {\n\t\tt.Errorf(\"priceBounds min = %d, want 19\", lo)\n\t}\n\t// max High = 60, padding +1 = 61\n\tif hi != 61 {\n\t\tt.Errorf(\"priceBounds max = %d, want 61\", hi)\n\t}\n}\n\nfunc TestRenderCandlestickChart_ExceedsMaxCandles(t *testing.T) {\n\tcandles := make([]CandleData, 60)\n\tfor i := range candles {\n\t\tcandles[i] = CandleData{\n\t\t\tLabel: \"T\",\n\t\t\tOpen: 50 + i,\n\t\t\tHigh: 60 + i,\n\t\t\tLow: 40 + i,\n\t\t\tClose: 55 + i,\n\t\t\tVolume: 100,\n\t\t}\n\t}\n\n\tout := captureOutput(func() {\n\t\tRenderCandlestickChart(candles, \"Many\")\n\t})\n\n\tif out == \"\" {\n\t\tt.Fatal(\"expected output for many candles\")\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4712,"content_sha256":"bdd40dac3038ad4b02d56fc9f6389d6288f7e93f44ab50b34edfe44d2624624e"},{"filename":"internal/ui/chart.go","content":"package ui\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n)\n\n// CandleData holds OHLCV data for chart rendering.\n// Uses int cents to avoid importing models package.\ntype CandleData struct {\n\tLabel string\n\tOpen, High, Low, Close int\n\tVolume int\n}\n\nconst (\n\tchartHeight = 16\n\tmaxChartCandles = 40\n)\n\nvar volumeBars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}\n\n// RenderCandlestickChart prints an ASCII candlestick chart to stdout.\nfunc RenderCandlestickChart(candles []CandleData, title string) {\n\tif len(candles) == 0 {\n\t\tfmt.Println(MutedStyle.Render(\" No candlestick data to chart.\"))\n\t\treturn\n\t}\n\n\t// Trim to most recent candles if too many\n\tvisible := candles\n\tif len(visible) > maxChartCandles {\n\t\tvisible = visible[len(visible)-maxChartCandles:]\n\t}\n\n\tpriceMin, priceMax := priceBounds(visible)\n\tif priceMin == priceMax {\n\t\tpriceMax = priceMin + 1\n\t}\n\n\t// Summary header\n\tfmt.Println()\n\tfmt.Print(\" \" + TitleStyle.Render(title))\n\tlastClose := visible[len(visible)-1].Close\n\tfirstOpen := visible[0].Open\n\tchange := lastClose - firstOpen\n\tchangePct := 0.0\n\tif firstOpen != 0 {\n\t\tchangePct = float64(change) / float64(firstOpen) * 100\n\t}\n\tsummary := fmt.Sprintf(\" Last: %s\", FormatPrice(lastClose))\n\tif change >= 0 {\n\t\tsummary += \" \" + PriceUpStyle.Render(fmt.Sprintf(\"+%s (%.1f%%)\", FormatPrice(change), changePct))\n\t} else {\n\t\tsummary += \" \" + PriceDownStyle.Render(fmt.Sprintf(\"%s (%.1f%%)\", FormatPrice(change), changePct))\n\t}\n\tfmt.Println(summary)\n\tfmt.Println()\n\n\t// Build chart grid\n\tgrid := buildGrid(visible, priceMin, priceMax)\n\n\t// Render rows with y-axis labels\n\tlabelInterval := labelStep(chartHeight)\n\tfor row := 0; row \u003c chartHeight; row++ {\n\t\tprice := rowToPrice(row, priceMin, priceMax)\n\t\tif row == 0 || row == chartHeight-1 || row%labelInterval == 0 {\n\t\t\tfmt.Printf(\" %7s │\", FormatPrice(price))\n\t\t} else {\n\t\t\tfmt.Print(\" │\")\n\t\t}\n\t\tfor col := 0; col \u003c len(visible); col++ {\n\t\t\tfmt.Print(grid[row][col])\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\t// X-axis line\n\tfmt.Print(\" └\")\n\tfmt.Println(strings.Repeat(\"─\", len(visible)*2))\n\n\t// X-axis labels\n\trenderXLabels(visible)\n\n\t// Volume sparkline\n\trenderVolumeLine(visible)\n\tfmt.Println()\n}\n\nfunc priceBounds(candles []CandleData) (int, int) {\n\tlo := math.MaxInt\n\thi := math.MinInt\n\tfor _, c := range candles {\n\t\tif c.Low \u003c lo {\n\t\t\tlo = c.Low\n\t\t}\n\t\tif c.High > hi {\n\t\t\thi = c.High\n\t\t}\n\t}\n\t// Add small padding (1 cent each side)\n\tif lo > 0 {\n\t\tlo--\n\t}\n\thi++\n\treturn lo, hi\n}\n\nfunc buildGrid(candles []CandleData, priceMin, priceMax int) [][]string {\n\tgrid := make([][]string, chartHeight)\n\tfor r := range grid {\n\t\tgrid[r] = make([]string, len(candles))\n\t\tfor c := range grid[r] {\n\t\t\tgrid[r][c] = \" \"\n\t\t}\n\t}\n\n\tfor col, candle := range candles {\n\t\thighRow := priceToRow(candle.High, priceMin, priceMax)\n\t\tlowRow := priceToRow(candle.Low, priceMin, priceMax)\n\n\t\topenRow := priceToRow(candle.Open, priceMin, priceMax)\n\t\tcloseRow := priceToRow(candle.Close, priceMin, priceMax)\n\n\t\t// Ensure body top \u003c= body bottom (row 0 = top)\n\t\tbodyTop := openRow\n\t\tbodyBot := closeRow\n\t\tif bodyTop > bodyBot {\n\t\t\tbodyTop, bodyBot = bodyBot, bodyTop\n\t\t}\n\n\t\tbullish := candle.Close >= candle.Open\n\t\tbodyStyle := PriceDownStyle\n\t\tif bullish {\n\t\t\tbodyStyle = PriceUpStyle\n\t\t}\n\n\t\tfor row := highRow; row \u003c= lowRow; row++ {\n\t\t\tif row >= bodyTop && row \u003c= bodyBot {\n\t\t\t\tif bodyTop == bodyBot {\n\t\t\t\t\t// Doji / flat candle\n\t\t\t\t\tgrid[row][col] = bodyStyle.Render(\"─ \")\n\t\t\t\t} else {\n\t\t\t\t\tgrid[row][col] = bodyStyle.Render(\"┃ \")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Wick\n\t\t\t\tgrid[row][col] = MutedStyle.Render(\"│ \")\n\t\t\t}\n\t\t}\n\t}\n\n\treturn grid\n}\n\nfunc priceToRow(price, priceMin, priceMax int) int {\n\tpriceRange := priceMax - priceMin\n\tif priceRange == 0 {\n\t\treturn chartHeight / 2\n\t}\n\t// row 0 = priceMax (top), row chartHeight-1 = priceMin (bottom)\n\tratio := float64(priceMax-price) / float64(priceRange)\n\trow := int(math.Round(ratio * float64(chartHeight-1)))\n\tif row \u003c 0 {\n\t\treturn 0\n\t}\n\tif row >= chartHeight {\n\t\treturn chartHeight - 1\n\t}\n\treturn row\n}\n\nfunc rowToPrice(row, priceMin, priceMax int) int {\n\tpriceRange := priceMax - priceMin\n\tif chartHeight \u003c= 1 {\n\t\treturn priceMin\n\t}\n\treturn priceMax - (row * priceRange / (chartHeight - 1))\n}\n\nfunc labelStep(height int) int {\n\tif height \u003c= 4 {\n\t\treturn 1\n\t}\n\treturn height / 4\n}\n\nfunc renderXLabels(candles []CandleData) {\n\tif len(candles) == 0 {\n\t\treturn\n\t}\n\n\ttype labelPos struct {\n\t\tcol int\n\t\tlabel string\n\t}\n\tvar labels []labelPos\n\n\tif len(candles) == 1 {\n\t\tlabels = append(labels, labelPos{0, candles[0].Label})\n\t} else if len(candles) \u003c= 5 {\n\t\tlabels = append(labels, labelPos{0, candles[0].Label})\n\t\tlabels = append(labels, labelPos{len(candles) - 1, candles[len(candles)-1].Label})\n\t} else {\n\t\tlabels = append(labels, labelPos{0, candles[0].Label})\n\t\tmid := len(candles) / 2\n\t\tlabels = append(labels, labelPos{mid, candles[mid].Label})\n\t\tlabels = append(labels, labelPos{len(candles) - 1, candles[len(candles)-1].Label})\n\t}\n\n\t// Extra space after last candle for label overflow\n\tmaxLabelLen := 0\n\tfor _, lp := range labels {\n\t\tif len(lp.label) > maxLabelLen {\n\t\t\tmaxLabelLen = len(lp.label)\n\t\t}\n\t}\n\ttotalWidth := len(candles)*2 + 11 + maxLabelLen\n\tbuf := make([]byte, totalWidth)\n\tfor i := range buf {\n\t\tbuf[i] = ' '\n\t}\n\n\t// Place labels, skipping if they'd overlap a previous one\n\tlastEnd := 0\n\tfor _, lp := range labels {\n\t\toffset := 11 + lp.col*2\n\t\tlbl := lp.label\n\t\tend := offset + len(lbl)\n\t\tif end > totalWidth {\n\t\t\tend = totalWidth\n\t\t\tlbl = lbl[:end-offset]\n\t\t}\n\t\tif offset \u003c lastEnd {\n\t\t\tcontinue // skip overlapping label\n\t\t}\n\t\tcopy(buf[offset:end], lbl)\n\t\tlastEnd = end + 1\n\t}\n\n\tfmt.Println(string(buf))\n}\n\nfunc renderVolumeLine(candles []CandleData) {\n\tif len(candles) == 0 {\n\t\treturn\n\t}\n\n\tmaxVol := 0\n\tfor _, c := range candles {\n\t\tif c.Volume > maxVol {\n\t\t\tmaxVol = c.Volume\n\t\t}\n\t}\n\n\tfmt.Print(\" \" + MutedStyle.Render(\"Vol\") + \" \")\n\tfor _, c := range candles {\n\t\tbar := volumeBar(c.Volume, maxVol)\n\t\tif c.Close >= c.Open {\n\t\t\tfmt.Print(PriceUpStyle.Render(string(bar)) + \" \")\n\t\t} else {\n\t\t\tfmt.Print(PriceDownStyle.Render(string(bar)) + \" \")\n\t\t}\n\t}\n\tfmt.Println()\n}\n\nfunc volumeBar(vol, maxVol int) rune {\n\tif maxVol == 0 || vol == 0 {\n\t\treturn volumeBars[0]\n\t}\n\tidx := int(float64(vol) / float64(maxVol) * float64(len(volumeBars)-1))\n\tif idx \u003c 0 {\n\t\treturn volumeBars[0]\n\t}\n\tif idx >= len(volumeBars) {\n\t\treturn volumeBars[len(volumeBars)-1]\n\t}\n\treturn volumeBars[idx]\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6464,"content_sha256":"a7ac288247f5538e5ab77bcd9859746d9c65e9c7b01de3d5c75a2429fe59889d"},{"filename":"internal/ui/json.go","content":"package ui\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc PrintJSON(v interface{}) error {\n\tencoder := json.NewEncoder(os.Stdout)\n\tencoder.SetIndent(\"\", \" \")\n\treturn encoder.Encode(v)\n}\n\nfunc PrintJSONCompact(v interface{}) error {\n\treturn json.NewEncoder(os.Stdout).Encode(v)\n}\n\nfunc ToJSONString(v interface{}) (string, error) {\n\tdata, err := json.MarshalIndent(v, \"\", \" \")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(data), nil\n}\n\nfunc PrintPlain(format string, args ...interface{}) {\n\tfmt.Printf(format+\"\\n\", args...)\n}\n\nfunc Output(format OutputFormat, tableFunc func(), jsonData interface{}, plainFunc func()) error {\n\tswitch format {\n\tcase FormatJSON:\n\t\treturn PrintJSON(jsonData)\n\tcase FormatPlain:\n\t\tplainFunc()\n\t\treturn nil\n\tdefault:\n\t\ttableFunc()\n\t\treturn nil\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":787,"content_sha256":"25ba10e06673f8bc902d3a1bb54e6e9fe5214c02b07e96cd86990634540ab586"},{"filename":"internal/ui/styles.go","content":"package ui\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\ntype OutputFormat int\n\nconst (\n\tFormatTable OutputFormat = iota\n\tFormatJSON\n\tFormatPlain\n)\n\nvar (\n\t// Colors\n\tprimaryColor = lipgloss.Color(\"#7C3AED\")\n\tsuccessColor = lipgloss.Color(\"#10B981\")\n\twarningColor = lipgloss.Color(\"#F59E0B\")\n\terrorColor = lipgloss.Color(\"#EF4444\")\n\tmutedColor = lipgloss.Color(\"#6B7280\")\n\taccentColor = lipgloss.Color(\"#3B82F6\")\n\n\t// Styles\n\tTitleStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(primaryColor)\n\n\tHeaderStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(accentColor)\n\n\tSuccessStyle = lipgloss.NewStyle().\n\t\t\tForeground(successColor)\n\n\tWarningStyle = lipgloss.NewStyle().\n\t\t\tForeground(warningColor)\n\n\tErrorStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(errorColor)\n\n\tMutedStyle = lipgloss.NewStyle().\n\t\t\tForeground(mutedColor)\n\n\tBoldStyle = lipgloss.NewStyle().\n\t\t\tBold(true)\n\n\t// Price styles\n\tPriceUpStyle = lipgloss.NewStyle().\n\t\t\tForeground(successColor)\n\n\tPriceDownStyle = lipgloss.NewStyle().\n\t\t\tForeground(errorColor)\n\n\t// Status styles\n\tStatusOpenStyle = lipgloss.NewStyle().\n\t\t\tForeground(successColor).\n\t\t\tBold(true)\n\n\tStatusClosedStyle = lipgloss.NewStyle().\n\t\t\tForeground(errorColor)\n\n\tStatusActiveStyle = lipgloss.NewStyle().\n\t\t\tForeground(accentColor).\n\t\t\tBold(true)\n\n\t// Environment indicator\n\tDemoStyle = lipgloss.NewStyle().\n\t\t\tBackground(lipgloss.Color(\"#FEF3C7\")).\n\t\t\tForeground(lipgloss.Color(\"#92400E\")).\n\t\t\tPadding(0, 1)\n\n\tProdStyle = lipgloss.NewStyle().\n\t\t\tBackground(lipgloss.Color(\"#FEE2E2\")).\n\t\t\tForeground(lipgloss.Color(\"#991B1B\")).\n\t\t\tPadding(0, 1).\n\t\t\tBold(true)\n)\n\nfunc FormatPrice(cents int) string {\n\tif cents \u003c 0 {\n\t\treturn fmt.Sprintf(\"-$%.2f\", float64(-cents)/100.0)\n\t}\n\treturn fmt.Sprintf(\"$%.2f\", float64(cents)/100.0)\n}\n\nfunc FormatPriceStyled(cents int, positive bool) string {\n\tabsCents := cents\n\tif absCents \u003c 0 {\n\t\tabsCents = -absCents\n\t}\n\tdollars := float64(absCents) / 100.0\n\tstyle := PriceDownStyle\n\tprefix := \"-\"\n\tif positive {\n\t\tstyle = PriceUpStyle\n\t\tprefix = \"+\"\n\t}\n\treturn style.Render(fmt.Sprintf(\"%s$%.2f\", prefix, dollars))\n}\n\nfunc FormatPercent(value float64) string {\n\treturn fmt.Sprintf(\"%.1f%%\", value*100)\n}\n\nfunc FormatQuantity(qty int) string {\n\treturn fmt.Sprintf(\"%d\", qty)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2295,"content_sha256":"1f8a7e5072d05b56f27a58e1e27ea157b4f08fb4fd435d23be712a866c5f392d"},{"filename":"internal/ui/table.go","content":"package ui\n\nimport (\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/olekukonko/tablewriter\"\n)\n\ntype TableOptions struct {\n\tHeaders []string\n\tColumnAlign []int\n\tNoHeader bool\n\tBorder bool\n}\n\nfunc NewTable(opts TableOptions) *tablewriter.Table {\n\treturn NewTableWriter(os.Stdout, opts)\n}\n\nfunc NewTableWriter(w io.Writer, opts TableOptions) *tablewriter.Table {\n\ttable := tablewriter.NewWriter(w)\n\n\tif len(opts.Headers) > 0 && !opts.NoHeader {\n\t\t// Convert []string to []any for the variadic Header method\n\t\theaders := make([]any, len(opts.Headers))\n\t\tfor i, h := range opts.Headers {\n\t\t\theaders[i] = h\n\t\t}\n\t\ttable.Header(headers...)\n\t}\n\n\treturn table\n}\n\nfunc RenderTable(headers []string, rows [][]string) {\n\ttable := NewTable(TableOptions{\n\t\tHeaders: headers,\n\t})\n\tfor _, row := range rows {\n\t\ttable.Append(row)\n\t}\n\ttable.Render()\n}\n\nfunc RenderKeyValue(pairs [][]string) {\n\ttable := NewTable(TableOptions{\n\t\tNoHeader: true,\n\t})\n\tfor _, pair := range pairs {\n\t\ttable.Append(pair)\n\t}\n\ttable.Render()\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":992,"content_sha256":"dd3f0534ae7f3dc8ea4fdd8b488e645e75becf0663412fe7c4c3c22279bcb1b0"},{"filename":"internal/websocket/channels_audit_test.go","content":"package websocket\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\n// TDD RED PHASE: Tests for missing channels and spec compliance issues\n// These tests document the expected API behavior from Kalshi's WebSocket spec\n\n// Test 1: All Kalshi channels should be defined\nfunc TestAllKalshiChannelsDefined(t *testing.T) {\n\texpectedChannels := []struct {\n\t\tname string\n\t\tchannel Channel\n\t\tauthRequired bool\n\t}{\n\t\t{\"ticker\", ChannelMarketTicker, false},\n\t\t{\"ticker_v2\", ChannelMarketTickerV2, false},\n\t\t{\"trade\", ChannelPublicTrades, false},\n\t\t{\"orderbook_delta\", ChannelOrderbook, true},\n\t\t{\"user_orders\", ChannelUserOrders, true},\n\t\t{\"fill\", ChannelUserFills, true},\n\t\t{\"market_positions\", ChannelMarketPositions, true},\n\t\t{\"order_group_updates\", ChannelOrderGroupUpdates, true},\n\t\t{\"communications\", ChannelCommunications, true},\n\t\t{\"market_lifecycle_v2\", ChannelMarketLifecycle, false},\n\t\t{\"multivariate\", ChannelMultivariateLookups, false},\n\t}\n\n\tfor _, tc := range expectedChannels {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif string(tc.channel) != tc.name {\n\t\t\t\tt.Errorf(\"expected channel constant for %s, got %s\", tc.name, tc.channel)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test 2: Ping interval should be 10 seconds per Kalshi spec\nfunc TestPingIntervalMatchesKalshiSpec(t *testing.T) {\n\t// Kalshi docs specify: \"Ping frames every 10 seconds, respond with Pong\"\n\texpectedInterval := 10 * time.Second\n\n\tif defaultPingInterval != expectedInterval {\n\t\tt.Errorf(\"ping interval should be %v (per Kalshi spec), got %v\", expectedInterval, defaultPingInterval)\n\t}\n}\n\n// Test 3: Channel requires authentication check should include orderbook\nfunc TestChannelAuthRequirements(t *testing.T) {\n\tauthRequiredChannels := map[Channel]bool{\n\t\tChannelOrderbook: true,\n\t\tChannelUserOrders: true,\n\t\tChannelUserFills: true,\n\t\tChannelMarketPositions: true,\n\t\tChannelOrderGroupUpdates: true,\n\t\tChannelCommunications: true,\n\t\tChannelMarketTicker: false,\n\t\tChannelMarketTickerV2: false,\n\t\tChannelPublicTrades: false,\n\t\tChannelMarketLifecycle: false,\n\t\tChannelMultivariateLookups: false,\n\t}\n\n\tfor channel, expected := range authRequiredChannels {\n\t\tt.Run(string(channel), func(t *testing.T) {\n\t\t\tresult := ChannelRequiresAuth(channel)\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"channel %s auth requirement: expected %v, got %v\", channel, expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test 4: MarketPositions data structure should exist\nfunc TestMarketPositionsDataStructure(t *testing.T) {\n\t// Per Kalshi spec, market_positions channel sends position updates\n\tdata := PositionData{\n\t\tTicker: \"BTC-100K\",\n\t\tPosition: 100,\n\t\tTotalCost: 5000,\n\t\tRealizedPnl: 250,\n\t\tExposure: 1000,\n\t}\n\n\tif data.Ticker != \"BTC-100K\" {\n\t\tt.Errorf(\"expected ticker BTC-100K, got %s\", data.Ticker)\n\t}\n}\n\n// Test 5: OrderGroupUpdate data structure should exist\nfunc TestOrderGroupUpdateDataStructure(t *testing.T) {\n\tdata := OrderGroupUpdateData{\n\t\tOrderGroupID: \"og-123\",\n\t\tStatus: \"active\",\n\t\tTotalOrders: 5,\n\t\tFilledOrders: 2,\n\t}\n\n\tif data.OrderGroupID != \"og-123\" {\n\t\tt.Errorf(\"expected order group ID og-123, got %s\", data.OrderGroupID)\n\t}\n}\n\n// Test 6: MarketLifecycle data structure should exist\nfunc TestMarketLifecycleDataStructure(t *testing.T) {\n\tdata := MarketLifecycleData{\n\t\tTicker: \"BTC-100K\",\n\t\tStatus: \"active\",\n\t\tOldStatus: \"inactive\",\n\t}\n\n\tif data.Status != \"active\" {\n\t\tt.Errorf(\"expected status active, got %s\", data.Status)\n\t}\n}\n\n// Test 7: Communication (RFQ/quote) data structure should exist\nfunc TestCommunicationDataStructure(t *testing.T) {\n\tdata := CommunicationData{\n\t\tType: \"rfq\",\n\t\tTicker: \"BTC-100K\",\n\t\tQuantity: 1000,\n\t}\n\n\tif data.Type != \"rfq\" {\n\t\tt.Errorf(\"expected type rfq, got %s\", data.Type)\n\t}\n}\n\n// Test 8: market_ticker_v2 should send incremental delta updates\nfunc TestMarketTickerV2DataStructure(t *testing.T) {\n\tdata := TickerV2Data{\n\t\tTicker: \"BTC-100K\",\n\t\tDeltaType: \"price_change\",\n\t\tYesPrice: 55,\n\t\tNoPrice: 45,\n\t\tDelta: 5,\n\t}\n\n\tif data.DeltaType != \"price_change\" {\n\t\tt.Errorf(\"expected delta_type price_change, got %s\", data.DeltaType)\n\t}\n}\n\n// Test 9: MultivariateLookups data structure should exist\nfunc TestMultivariateLookupDataStructure(t *testing.T) {\n\tdata := MultivariateLookupData{\n\t\tSeriesID: \"series-abc\",\n\t\tLookupValue: \"result\",\n\t}\n\n\tif data.SeriesID != \"series-abc\" {\n\t\tt.Errorf(\"expected series ID series-abc, got %s\", data.SeriesID)\n\t}\n}\n\n// Test 10: Subscription manager should track all channel types\nfunc TestSubscriptionManagerAllChannels(t *testing.T) {\n\tallChannels := []Channel{\n\t\tChannelMarketTicker,\n\t\tChannelMarketTickerV2,\n\t\tChannelPublicTrades,\n\t\tChannelOrderbook,\n\t\tChannelUserOrders,\n\t\tChannelUserFills,\n\t\tChannelMarketPositions,\n\t\tChannelOrderGroupUpdates,\n\t\tChannelCommunications,\n\t\tChannelMarketLifecycle,\n\t\tChannelMultivariateLookups,\n\t}\n\n\tsm := NewSubscriptionManager()\n\n\tfor _, ch := range allChannels {\n\t\tt.Run(string(ch), func(t *testing.T) {\n\t\t\t_, err := sm.Subscribe(ch, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"failed to subscribe to %s: %v\", ch, err)\n\t\t\t}\n\t\t\tif !sm.IsSubscribed(ch) {\n\t\t\t\tt.Errorf(\"channel %s should be subscribed\", ch)\n\t\t\t}\n\t\t})\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5157,"content_sha256":"aabe56923626004ccd5dcadc4058f0a449df7b494472c9a1a30e33bf06d16ec7"},{"filename":"internal/websocket/channels_test.go","content":"package websocket\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestNewSubscriptionManager(t *testing.T) {\n\tsm := NewSubscriptionManager()\n\n\tif sm == nil {\n\t\tt.Fatal(\"NewSubscriptionManager returned nil\")\n\t}\n\n\tif sm.subscriptions == nil {\n\t\tt.Error(\"subscriptions map should be initialized\")\n\t}\n\n\tif sm.nextID != 2 {\n\t\tt.Errorf(\"nextID should start at 2 (1 reserved for auth), got %d\", sm.nextID)\n\t}\n}\n\nfunc TestSubscriptionManager_Subscribe(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tchannel Channel\n\t\tparams map[string]string\n\t}{\n\t\t{\n\t\t\tname: \"subscribe to market_ticker\",\n\t\t\tchannel: ChannelMarketTicker,\n\t\t\tparams: map[string]string{\"market_ticker\": \"BTC-100K\"},\n\t\t},\n\t\t{\n\t\t\tname: \"subscribe to orderbook\",\n\t\t\tchannel: ChannelOrderbook,\n\t\t\tparams: map[string]string{\"orderbook\": \"BTC-100K\"},\n\t\t},\n\t\t{\n\t\t\tname: \"subscribe to public_trades\",\n\t\t\tchannel: ChannelPublicTrades,\n\t\t\tparams: map[string]string{\"public_trades\": \"BTC-100K\"},\n\t\t},\n\t\t{\n\t\t\tname: \"subscribe to user_orders\",\n\t\t\tchannel: ChannelUserOrders,\n\t\t\tparams: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"subscribe to user_fills\",\n\t\t\tchannel: ChannelUserFills,\n\t\t\tparams: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsm := NewSubscriptionManager()\n\n\t\t\tcmd, err := sm.Subscribe(tt.channel, tt.params)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Subscribe failed: %v\", err)\n\t\t\t}\n\n\t\t\tif cmd.Cmd != CmdSubscribe {\n\t\t\t\tt.Errorf(\"expected cmd '%s', got '%s'\", CmdSubscribe, cmd.Cmd)\n\t\t\t}\n\n\t\t\tif cmd.ID \u003c 2 {\n\t\t\t\tt.Errorf(\"command ID should be >= 2, got %d\", cmd.ID)\n\t\t\t}\n\n\t\t\t// Verify channel is in params\n\t\t\tchannels, ok := cmd.Params[\"channels\"].([]Channel)\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"channels not found in params\")\n\t\t\t}\n\n\t\t\tif len(channels) != 1 || channels[0] != tt.channel {\n\t\t\t\tt.Errorf(\"expected channel %s, got %v\", tt.channel, channels)\n\t\t\t}\n\n\t\t\t// Verify subscription is tracked\n\t\t\tif !sm.IsSubscribed(tt.channel) {\n\t\t\t\tt.Error(\"channel should be marked as subscribed\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscriptionManager_Unsubscribe(t *testing.T) {\n\tsm := NewSubscriptionManager()\n\n\t// Subscribe first\n\t_, err := sm.Subscribe(ChannelMarketTicker, map[string]string{\"market_ticker\": \"BTC-100K\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Subscribe failed: %v\", err)\n\t}\n\n\t// Then unsubscribe\n\tcmd, err := sm.Unsubscribe(ChannelMarketTicker)\n\tif err != nil {\n\t\tt.Fatalf(\"Unsubscribe failed: %v\", err)\n\t}\n\n\tif cmd.Cmd != CmdUnsubscribe {\n\t\tt.Errorf(\"expected cmd '%s', got '%s'\", CmdUnsubscribe, cmd.Cmd)\n\t}\n\n\t// Verify unsubscribed\n\tif sm.IsSubscribed(ChannelMarketTicker) {\n\t\tt.Error(\"channel should not be subscribed after unsubscribe\")\n\t}\n}\n\nfunc TestSubscriptionManager_UnsubscribeNotSubscribed(t *testing.T) {\n\tsm := NewSubscriptionManager()\n\n\t_, err := sm.Unsubscribe(ChannelMarketTicker)\n\tif err == nil {\n\t\tt.Error(\"expected error when unsubscribing from non-subscribed channel\")\n\t}\n}\n\nfunc TestSubscriptionManager_GetSubscriptions(t *testing.T) {\n\tsm := NewSubscriptionManager()\n\n\tsm.Subscribe(ChannelMarketTicker, map[string]string{\"market_ticker\": \"BTC-100K\"})\n\tsm.Subscribe(ChannelOrderbook, map[string]string{\"orderbook\": \"ETH-5K\"})\n\n\tsubs := sm.GetSubscriptions()\n\n\tif len(subs) != 2 {\n\t\tt.Errorf(\"expected 2 subscriptions, got %d\", len(subs))\n\t}\n}\n\nfunc TestCommand_MarshalJSON(t *testing.T) {\n\tcmd := Command{\n\t\tID: 1,\n\t\tCmd: CmdSubscribe,\n\t\tParams: map[string]interface{}{\n\t\t\t\"channels\": []Channel{ChannelMarketTicker},\n\t\t\t\"market_ticker\": \"BTC-100K\",\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(cmd)\n\tif err != nil {\n\t\tt.Fatalf(\"Marshal failed: %v\", err)\n\t}\n\n\t// Parse back to verify structure\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"Unmarshal failed: %v\", err)\n\t}\n\n\tif parsed[\"id\"].(float64) != 1 {\n\t\tt.Errorf(\"expected id 1, got %v\", parsed[\"id\"])\n\t}\n\n\tif parsed[\"cmd\"].(string) != string(CmdSubscribe) {\n\t\tt.Errorf(\"expected cmd '%s', got %s\", CmdSubscribe, parsed[\"cmd\"])\n\t}\n\n\tparams := parsed[\"params\"].(map[string]interface{})\n\tif params[\"market_ticker\"].(string) != \"BTC-100K\" {\n\t\tt.Errorf(\"expected market_ticker 'BTC-100K', got %v\", params[\"market_ticker\"])\n\t}\n}\n\nfunc TestBuildAuthCommand(t *testing.T) {\n\tapiKeyID := \"test-key-id\"\n\tsignature := \"test-signature\"\n\ttimestamp := \"2024-01-15T12:00:00Z\"\n\n\tcmd := BuildAuthCommand(apiKeyID, signature, timestamp)\n\n\tif cmd.ID != AuthCommandID {\n\t\tt.Errorf(\"auth command ID should be %d, got %d\", AuthCommandID, cmd.ID)\n\t}\n\n\tif cmd.Cmd != CmdAuth {\n\t\tt.Errorf(\"expected cmd '%s', got '%s'\", CmdAuth, cmd.Cmd)\n\t}\n\n\tparams := cmd.Params\n\tif params[\"api_key\"].(string) != apiKeyID {\n\t\tt.Errorf(\"expected api_key '%s', got '%v'\", apiKeyID, params[\"api_key\"])\n\t}\n\n\tif params[\"signature\"].(string) != signature {\n\t\tt.Errorf(\"expected signature '%s', got '%v'\", signature, params[\"signature\"])\n\t}\n\n\tif params[\"timestamp\"].(string) != timestamp {\n\t\tt.Errorf(\"expected timestamp '%s', got '%v'\", timestamp, params[\"timestamp\"])\n\t}\n}\n\nfunc TestSubscription_Restore(t *testing.T) {\n\tsm := NewSubscriptionManager()\n\n\t// Subscribe to multiple channels\n\tsm.Subscribe(ChannelMarketTicker, map[string]string{\"market_ticker\": \"BTC-100K\"})\n\tsm.Subscribe(ChannelOrderbook, map[string]string{\"orderbook\": \"BTC-100K\"})\n\n\t// Get subscriptions for restore\n\tsubs := sm.GetSubscriptions()\n\n\t// Create new manager and restore\n\tnewSM := NewSubscriptionManager()\n\tcommands := newSM.RestoreSubscriptions(subs)\n\n\tif len(commands) != 2 {\n\t\tt.Errorf(\"expected 2 restore commands, got %d\", len(commands))\n\t}\n\n\tfor _, cmd := range commands {\n\t\tif cmd.Cmd != CmdSubscribe {\n\t\t\tt.Errorf(\"restore command should be subscribe, got %s\", cmd.Cmd)\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5630,"content_sha256":"431c6764926cba6d1c703ae9edca184055cd27997c7c7476039fdc13ecd811c6"},{"filename":"internal/websocket/channels.go","content":"package websocket\n\nimport (\n\t\"errors\"\n\t\"sync\"\n)\n\n// CommandType represents a WebSocket command type\ntype CommandType string\n\nconst (\n\tCmdAuth CommandType = \"auth\"\n\tCmdSubscribe CommandType = \"subscribe\"\n\tCmdUnsubscribe CommandType = \"unsubscribe\"\n\tCmdPing CommandType = \"ping\"\n)\n\n// AuthCommandID is the reserved command ID for authentication\nconst AuthCommandID = 1\n\n// Command represents a WebSocket command to send to the server\ntype Command struct {\n\tID int `json:\"id\"`\n\tCmd CommandType `json:\"cmd\"`\n\tParams map[string]interface{} `json:\"params\"`\n}\n\n// Subscription represents an active channel subscription\ntype Subscription struct {\n\tChannel Channel\n\tParams map[string]string\n}\n\n// SubscriptionManager manages channel subscriptions\ntype SubscriptionManager struct {\n\tsubscriptions map[Channel]Subscription\n\tnextID int\n\tmu sync.RWMutex\n}\n\n// NewSubscriptionManager creates a new subscription manager\nfunc NewSubscriptionManager() *SubscriptionManager {\n\treturn &SubscriptionManager{\n\t\tsubscriptions: make(map[Channel]Subscription),\n\t\tnextID: 2, // Start at 2 since 1 is reserved for auth\n\t}\n}\n\n// Subscribe creates a subscription command for a channel\nfunc (sm *SubscriptionManager) Subscribe(channel Channel, params map[string]string) (*Command, error) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tid := sm.nextID\n\tsm.nextID++\n\n\tcmdParams := map[string]interface{}{\n\t\t\"channels\": []Channel{channel},\n\t}\n\n\t// Add channel-specific params\n\tfor k, v := range params {\n\t\tcmdParams[k] = v\n\t}\n\n\tsm.subscriptions[channel] = Subscription{\n\t\tChannel: channel,\n\t\tParams: params,\n\t}\n\n\treturn &Command{\n\t\tID: id,\n\t\tCmd: CmdSubscribe,\n\t\tParams: cmdParams,\n\t}, nil\n}\n\n// Unsubscribe creates an unsubscribe command for a channel\nfunc (sm *SubscriptionManager) Unsubscribe(channel Channel) (*Command, error) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tif _, ok := sm.subscriptions[channel]; !ok {\n\t\treturn nil, errors.New(\"not subscribed to channel\")\n\t}\n\n\tid := sm.nextID\n\tsm.nextID++\n\n\tdelete(sm.subscriptions, channel)\n\n\treturn &Command{\n\t\tID: id,\n\t\tCmd: CmdUnsubscribe,\n\t\tParams: map[string]interface{}{\n\t\t\t\"channels\": []Channel{channel},\n\t\t},\n\t}, nil\n}\n\n// IsSubscribed checks if a channel is currently subscribed\nfunc (sm *SubscriptionManager) IsSubscribed(channel Channel) bool {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\t_, ok := sm.subscriptions[channel]\n\treturn ok\n}\n\n// GetSubscriptions returns all current subscriptions\nfunc (sm *SubscriptionManager) GetSubscriptions() []Subscription {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\tsubs := make([]Subscription, 0, len(sm.subscriptions))\n\tfor _, sub := range sm.subscriptions {\n\t\tsubs = append(subs, sub)\n\t}\n\treturn subs\n}\n\n// RestoreSubscriptions creates subscribe commands for restoring subscriptions after reconnect\nfunc (sm *SubscriptionManager) RestoreSubscriptions(subs []Subscription) []*Command {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tcommands := make([]*Command, 0, len(subs))\n\n\tfor _, sub := range subs {\n\t\tid := sm.nextID\n\t\tsm.nextID++\n\n\t\tcmdParams := map[string]interface{}{\n\t\t\t\"channels\": []Channel{sub.Channel},\n\t\t}\n\n\t\tfor k, v := range sub.Params {\n\t\t\tcmdParams[k] = v\n\t\t}\n\n\t\tsm.subscriptions[sub.Channel] = sub\n\n\t\tcommands = append(commands, &Command{\n\t\t\tID: id,\n\t\t\tCmd: CmdSubscribe,\n\t\t\tParams: cmdParams,\n\t\t})\n\t}\n\n\treturn commands\n}\n\n// Clear removes all subscriptions (used during disconnect)\nfunc (sm *SubscriptionManager) Clear() {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tsm.subscriptions = make(map[Channel]Subscription)\n}\n\n// BuildAuthCommand creates an authentication command\nfunc BuildAuthCommand(apiKeyID, signature, timestamp string) *Command {\n\treturn &Command{\n\t\tID: AuthCommandID,\n\t\tCmd: CmdAuth,\n\t\tParams: map[string]interface{}{\n\t\t\t\"api_key\": apiKeyID,\n\t\t\t\"signature\": signature,\n\t\t\t\"timestamp\": timestamp,\n\t\t},\n\t}\n}\n\n// BuildPingCommand creates a ping command\nfunc BuildPingCommand(id int) *Command {\n\treturn &Command{\n\t\tID: id,\n\t\tCmd: CmdPing,\n\t\tParams: map[string]interface{}{},\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4053,"content_sha256":"fda4ac715a9c5b8850d5c25e86f92d65327b40dac3d2628099ed20ec9bcf5a0b"},{"filename":"internal/websocket/client_test.go","content":"package websocket\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"nhooyr.io/websocket\"\n)\n\nfunc TestNewClient(t *testing.T) {\n\topts := ClientOptions{\n\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\n\tif client == nil {\n\t\tt.Fatal(\"NewClient returned nil\")\n\t}\n\n\tif client.url != opts.URL {\n\t\tt.Errorf(\"expected URL '%s', got '%s'\", opts.URL, client.url)\n\t}\n\n\tif client.apiKeyID != opts.APIKeyID {\n\t\tt.Errorf(\"expected apiKeyID '%s', got '%s'\", opts.APIKeyID, client.apiKeyID)\n\t}\n\n\tif client.subscriptions == nil {\n\t\tt.Error(\"subscriptions should be initialized\")\n\t}\n\n\tif client.router == nil {\n\t\tt.Error(\"router should be initialized\")\n\t}\n}\n\nfunc TestClientOptions_Validate(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\topts ClientOptions\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname: \"valid options\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"test-key\",\n\t\t\t\tSignature: \"test-sig\",\n\t\t\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"missing URL\",\n\t\t\topts: ClientOptions{\n\t\t\t\tAPIKeyID: \"test-key\",\n\t\t\t\tSignature: \"test-sig\",\n\t\t\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"missing APIKeyID\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tSignature: \"test-sig\",\n\t\t\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"missing Signature\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"test-key\",\n\t\t\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"missing Timestamp\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"test-key\",\n\t\t\t\tSignature: \"test-sig\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.opts.Validate()\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClient_Connect(t *testing.T) {\n\t// Create test WebSocket server — auth is via HTTP headers on upgrade,\n\t// no post-connect auth command expected\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\t// Keep connection open\n\t\t\u003c-context.Background().Done()\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := client.Connect(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\tif !client.IsConnected() {\n\t\tt.Error(\"client should be connected\")\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_Reconnect(t *testing.T) {\n\tconnectionCount := 0\n\tvar mu sync.Mutex\n\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\tmu.Lock()\n\t\tconnectionCount++\n\t\tcount := connectionCount\n\t\tmu.Unlock()\n\n\t\t// First connection: close immediately to trigger reconnect\n\t\tif count == 1 {\n\t\t\tconn.Close(websocket.StatusGoingAway, \"test disconnect\")\n\t\t\treturn\n\t\t}\n\n\t\t// Second connection: stay open\n\t\t\u003c-context.Background().Done()\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\tReconnectBaseDelay: 10 * time.Millisecond,\n\t\tReconnectMaxDelay: 50 * time.Millisecond,\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\terr := client.Connect(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\t// Wait for reconnect\n\ttime.Sleep(200 * time.Millisecond)\n\n\tmu.Lock()\n\tfinalCount := connectionCount\n\tmu.Unlock()\n\n\tif finalCount \u003c 2 {\n\t\tt.Errorf(\"expected at least 2 connections (initial + reconnect), got %d\", finalCount)\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_Subscribe(t *testing.T) {\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\tctx := context.Background()\n\n\t\t// Handle subscribe (first message after connect, no auth command)\n\t\t_, data, err := conn.Read(ctx)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tvar cmd Command\n\t\tjson.Unmarshal(data, &cmd)\n\t\tif cmd.Cmd != CmdSubscribe {\n\t\t\tt.Errorf(\"expected subscribe command, got %s\", cmd.Cmd)\n\t\t}\n\n\t\t// Send subscribe response\n\t\tresponse := Message{ID: cmd.ID, Type: \"response\"}\n\t\trespData, _ := json.Marshal(response)\n\t\tconn.Write(ctx, websocket.MessageText, respData)\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := client.Connect(ctx); err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\terr := client.Subscribe(ctx, ChannelMarketTicker, map[string]string{\"market_ticker\": \"BTC-100K\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Subscribe failed: %v\", err)\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_Unsubscribe(t *testing.T) {\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\tctx := context.Background()\n\n\t\t// Handle subscribe\n\t\t_, data, _ := conn.Read(ctx)\n\t\tvar cmd Command\n\t\tjson.Unmarshal(data, &cmd)\n\t\tresponse := Message{ID: cmd.ID, Type: \"response\"}\n\t\trespData, _ := json.Marshal(response)\n\t\tconn.Write(ctx, websocket.MessageText, respData)\n\n\t\t// Handle unsubscribe\n\t\t_, data, err := conn.Read(ctx)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tjson.Unmarshal(data, &cmd)\n\t\tif cmd.Cmd != CmdUnsubscribe {\n\t\t\tt.Errorf(\"expected unsubscribe command, got %s\", cmd.Cmd)\n\t\t}\n\n\t\tresponse = Message{ID: cmd.ID, Type: \"response\"}\n\t\trespData, _ = json.Marshal(response)\n\t\tconn.Write(ctx, websocket.MessageText, respData)\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := client.Connect(ctx); err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\t// Subscribe first\n\tif err := client.Subscribe(ctx, ChannelMarketTicker, map[string]string{\"market_ticker\": \"BTC-100K\"}); err != nil {\n\t\tt.Fatalf(\"Subscribe failed: %v\", err)\n\t}\n\n\t// Then unsubscribe\n\terr := client.Unsubscribe(ctx, ChannelMarketTicker)\n\tif err != nil {\n\t\tt.Fatalf(\"Unsubscribe failed: %v\", err)\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_RegisterHandler(t *testing.T) {\n\tcalled := make(chan bool, 1)\n\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\tctx := context.Background()\n\n\t\t// Send a ticker message immediately (no auth command to handle)\n\t\tmsg := Message{\n\t\t\tType: \"ticker\",\n\t\t\tChannel: ChannelMarketTicker,\n\t\t\tData: json.RawMessage(`{\"ticker\":\"BTC-100K\",\"yes_price\":55}`),\n\t\t}\n\t\tmsgData, _ := json.Marshal(msg)\n\t\tconn.Write(ctx, websocket.MessageText, msgData)\n\n\t\t// Keep connection open\n\t\t\u003c-ctx.Done()\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\n\t// Register handler before connecting\n\tclient.RegisterHandler(ChannelMarketTicker, &MockHandler{\n\t\tonMessage: func(msg Message) error {\n\t\t\tcalled \u003c- true\n\t\t\treturn nil\n\t\t},\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := client.Connect(ctx); err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\tselect {\n\tcase \u003c-called:\n\t\t// Success\n\tcase \u003c-time.After(2 * time.Second):\n\t\tt.Error(\"handler was not called within timeout\")\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_Ping(t *testing.T) {\n\tpingReceived := make(chan bool, 1)\n\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\tctx := context.Background()\n\n\t\t// Wait for ping (no auth command to handle)\n\t\tfor {\n\t\t\t_, data, err := conn.Read(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar cmd Command\n\t\t\tif err := json.Unmarshal(data, &cmd); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif cmd.Cmd == CmdPing {\n\t\t\t\tpingReceived \u003c- true\n\t\t\t\t// Send pong response\n\t\t\t\tresponse := Message{ID: cmd.ID, Type: \"pong\"}\n\t\t\t\trespData, _ := json.Marshal(response)\n\t\t\t\tconn.Write(ctx, websocket.MessageText, respData)\n\t\t\t}\n\t\t}\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t\tPingInterval: 100 * time.Millisecond,\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := client.Connect(ctx); err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\tselect {\n\tcase \u003c-pingReceived:\n\t\t// Success\n\tcase \u003c-time.After(2 * time.Second):\n\t\tt.Error(\"ping was not received within timeout\")\n\t}\n\n\tclient.Close()\n}\n\nfunc TestClient_Close(t *testing.T) {\n\tserver := newTestWSServer(t, func(conn *websocket.Conn) {\n\t\t\u003c-context.Background().Done()\n\t})\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\topts := ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"test-key\",\n\t\tSignature: \"test-sig\",\n\t\tTimestamp: \"2024-01-15T12:00:00Z\",\n\t}\n\n\tclient := NewClient(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := client.Connect(ctx); err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\n\tclient.Close()\n\n\tif client.IsConnected() {\n\t\tt.Error(\"client should not be connected after Close\")\n\t}\n}\n\nfunc TestExponentialBackoff(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tattempt int\n\t\tbase time.Duration\n\t\tmax time.Duration\n\t\texpected time.Duration\n\t}{\n\t\t{\n\t\t\tname: \"first attempt\",\n\t\t\tattempt: 0,\n\t\t\tbase: 100 * time.Millisecond,\n\t\t\tmax: 10 * time.Second,\n\t\t\texpected: 100 * time.Millisecond,\n\t\t},\n\t\t{\n\t\t\tname: \"second attempt\",\n\t\t\tattempt: 1,\n\t\t\tbase: 100 * time.Millisecond,\n\t\t\tmax: 10 * time.Second,\n\t\t\texpected: 200 * time.Millisecond,\n\t\t},\n\t\t{\n\t\t\tname: \"third attempt\",\n\t\t\tattempt: 2,\n\t\t\tbase: 100 * time.Millisecond,\n\t\t\tmax: 10 * time.Second,\n\t\t\texpected: 400 * time.Millisecond,\n\t\t},\n\t\t{\n\t\t\tname: \"capped at max\",\n\t\t\tattempt: 10,\n\t\t\tbase: 100 * time.Millisecond,\n\t\t\tmax: 1 * time.Second,\n\t\t\texpected: 1 * time.Second,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := calculateBackoff(tt.attempt, tt.base, tt.max)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConnect_SendsAuthHeadersOnUpgrade(t *testing.T) {\n\tvar receivedHeaders http.Header\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedHeaders = r.Header.Clone()\n\n\t\tconn, err := websocket.Accept(w, r, nil)\n\t\tif err != nil {\n\t\t\tt.Logf(\"websocket accept error: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tdefer conn.Close(websocket.StatusNormalClosure, \"\")\n\n\t\t\u003c-r.Context().Done()\n\t}))\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\tclient := NewClient(ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"my-api-key\",\n\t\tSignature: \"my-signature\",\n\t\tTimestamp: \"1707500000000\",\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := client.Connect(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"Connect failed: %v\", err)\n\t}\n\tdefer client.Close()\n\n\ttests := []struct {\n\t\theader string\n\t\twant string\n\t}{\n\t\t{\"Kalshi-Access-Key\", \"my-api-key\"},\n\t\t{\"Kalshi-Access-Signature\", \"my-signature\"},\n\t\t{\"Kalshi-Access-Timestamp\", \"1707500000000\"},\n\t}\n\n\tfor _, tc := range tests {\n\t\tgot := receivedHeaders.Get(tc.header)\n\t\tif got != tc.want {\n\t\t\tt.Errorf(\"header %s = %q, want %q\", tc.header, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestConnect_Rejects401_WhenHeadersMissing(t *testing.T) {\n\t// Server that requires auth headers on the HTTP upgrade\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Kalshi-Access-Key\") == \"\" {\n\t\t\thttp.Error(w, \"Unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tconn, err := websocket.Accept(w, r, nil)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdefer conn.Close(websocket.StatusNormalClosure, \"\")\n\n\t\t\u003c-r.Context().Done()\n\t}))\n\tdefer server.Close()\n\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\")\n\n\tclient := NewClient(ClientOptions{\n\t\tURL: wsURL,\n\t\tAPIKeyID: \"my-key\",\n\t\tSignature: \"my-sig\",\n\t\tTimestamp: \"1234567890\",\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t// With auth headers on dial, this should succeed\n\terr := client.Connect(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"Connect should succeed when auth headers are present, got: %v\", err)\n\t}\n\tdefer client.Close()\n}\n\nfunc TestClientOptions_Validate_RejectsPlaceholders(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\topts ClientOptions\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname: \"rejects anonymous API key\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"anonymous\",\n\t\t\t\tSignature: \"real-sig\",\n\t\t\t\tTimestamp: \"1707500000000\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"rejects none signature\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"real-key\",\n\t\t\t\tSignature: \"none\",\n\t\t\t\tTimestamp: \"1707500000000\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"accepts real credentials\",\n\t\t\topts: ClientOptions{\n\t\t\t\tURL: \"wss://demo-api.kalshi.co/trade-api/ws/v2\",\n\t\t\t\tAPIKeyID: \"real-key-id\",\n\t\t\t\tSignature: \"real-base64-sig\",\n\t\t\t\tTimestamp: \"1707500000000\",\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.opts.Validate()\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper function to create test WebSocket server\nfunc newTestWSServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {\n\tt.Helper()\n\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tconn, err := websocket.Accept(w, r, nil)\n\t\tif err != nil {\n\t\t\tt.Logf(\"websocket accept error: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tdefer conn.Close(websocket.StatusNormalClosure, \"\")\n\n\t\thandler(conn)\n\t}))\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":15036,"content_sha256":"cb0c5cf25e8a2472ffcf32a61ed71ff33478699abc4ef88a4bd1fbf894db7775"},{"filename":"internal/websocket/client.go","content":"package websocket\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"nhooyr.io/websocket\"\n)\n\nconst (\n\t// Kalshi spec: \"Ping frames every 10 seconds, respond with Pong\"\n\tdefaultPingInterval = 10 * time.Second\n\tdefaultReconnectBaseDelay = 1 * time.Second\n\tdefaultReconnectMaxDelay = 60 * time.Second\n\tdefaultWriteTimeout = 10 * time.Second\n\tdefaultReadTimeout = 60 * time.Second\n)\n\n// ClientOptions contains configuration for the WebSocket client\ntype ClientOptions struct {\n\tURL string\n\tAPIKeyID string\n\tSignature string\n\tTimestamp string\n\tPingInterval time.Duration\n\tReconnectBaseDelay time.Duration\n\tReconnectMaxDelay time.Duration\n\tWriteTimeout time.Duration\n\tReadTimeout time.Duration\n}\n\n// Validate checks that required options are set\nfunc (o *ClientOptions) Validate() error {\n\tif o.URL == \"\" {\n\t\treturn errors.New(\"URL is required\")\n\t}\n\tif o.APIKeyID == \"\" {\n\t\treturn errors.New(\"APIKeyID is required\")\n\t}\n\tif o.APIKeyID == \"anonymous\" {\n\t\treturn errors.New(\"APIKeyID cannot be 'anonymous'; real credentials are required\")\n\t}\n\tif o.Signature == \"\" {\n\t\treturn errors.New(\"Signature is required\")\n\t}\n\tif o.Signature == \"none\" {\n\t\treturn errors.New(\"Signature cannot be 'none'; real credentials are required\")\n\t}\n\tif o.Timestamp == \"\" {\n\t\treturn errors.New(\"Timestamp is required\")\n\t}\n\treturn nil\n}\n\n// Client manages a WebSocket connection to Kalshi\ntype Client struct {\n\turl string\n\tapiKeyID string\n\tsignature string\n\ttimestamp string\n\n\tconn *websocket.Conn\n\tconnected atomic.Bool\n\tsubscriptions *SubscriptionManager\n\trouter *MessageRouter\n\n\tpingInterval time.Duration\n\treconnectBaseDelay time.Duration\n\treconnectMaxDelay time.Duration\n\twriteTimeout time.Duration\n\treadTimeout time.Duration\n\n\tpendingResponses map[int]chan *Message\n\tpendingMu sync.RWMutex\n\tnextPingID int\n\n\tcancelFunc context.CancelFunc\n\twg sync.WaitGroup\n\tcloseMu sync.Mutex\n\tclosed bool\n\n\tonReconnect func()\n\tonError func(error)\n}\n\n// NewClient creates a new WebSocket client\nfunc NewClient(opts ClientOptions) *Client {\n\tpingInterval := opts.PingInterval\n\tif pingInterval == 0 {\n\t\tpingInterval = defaultPingInterval\n\t}\n\n\treconnectBaseDelay := opts.ReconnectBaseDelay\n\tif reconnectBaseDelay == 0 {\n\t\treconnectBaseDelay = defaultReconnectBaseDelay\n\t}\n\n\treconnectMaxDelay := opts.ReconnectMaxDelay\n\tif reconnectMaxDelay == 0 {\n\t\treconnectMaxDelay = defaultReconnectMaxDelay\n\t}\n\n\twriteTimeout := opts.WriteTimeout\n\tif writeTimeout == 0 {\n\t\twriteTimeout = defaultWriteTimeout\n\t}\n\n\treadTimeout := opts.ReadTimeout\n\tif readTimeout == 0 {\n\t\treadTimeout = defaultReadTimeout\n\t}\n\n\treturn &Client{\n\t\turl: opts.URL,\n\t\tapiKeyID: opts.APIKeyID,\n\t\tsignature: opts.Signature,\n\t\ttimestamp: opts.Timestamp,\n\t\tsubscriptions: NewSubscriptionManager(),\n\t\trouter: NewMessageRouter(),\n\t\tpingInterval: pingInterval,\n\t\treconnectBaseDelay: reconnectBaseDelay,\n\t\treconnectMaxDelay: reconnectMaxDelay,\n\t\twriteTimeout: writeTimeout,\n\t\treadTimeout: readTimeout,\n\t\tpendingResponses: make(map[int]chan *Message),\n\t\tnextPingID: 1000, // Start ping IDs at 1000 to avoid conflicts\n\t}\n}\n\n// buildDialOptions constructs WebSocket dial options with authentication headers.\n// Kalshi requires these headers on the HTTP upgrade request.\nfunc (c *Client) buildDialOptions() *websocket.DialOptions {\n\treturn &websocket.DialOptions{\n\t\tHTTPHeader: http.Header{\n\t\t\t\"KALSHI-ACCESS-KEY\": []string{c.apiKeyID},\n\t\t\t\"KALSHI-ACCESS-SIGNATURE\": []string{c.signature},\n\t\t\t\"KALSHI-ACCESS-TIMESTAMP\": []string{c.timestamp},\n\t\t},\n\t}\n}\n\n// Connect establishes a WebSocket connection and authenticates\nfunc (c *Client) Connect(ctx context.Context) error {\n\tif err := c.connect(ctx); err != nil {\n\t\treturn err\n\t}\n\n\t// Create a context for the background goroutines\n\tbgCtx, cancel := context.WithCancel(context.Background())\n\tc.cancelFunc = cancel\n\n\t// Start read loop\n\tc.wg.Add(1)\n\tgo c.readLoop(bgCtx)\n\n\t// Start ping loop\n\tc.wg.Add(1)\n\tgo c.pingLoop(bgCtx)\n\n\treturn nil\n}\n\n// connect performs the actual connection.\n// Authentication is handled via HTTP headers on the WebSocket upgrade request\n// (set by buildDialOptions), not via a post-connect command.\nfunc (c *Client) connect(ctx context.Context) error {\n\tconn, _, err := websocket.Dial(ctx, c.url, c.buildDialOptions())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to dial WebSocket: %w\", err)\n\t}\n\n\tc.conn = conn\n\tc.connected.Store(true)\n\treturn nil\n}\n\n// readLoop continuously reads messages from the WebSocket\nfunc (c *Client) readLoop(ctx context.Context) {\n\tdefer c.wg.Done()\n\n\treconnectAttempt := 0\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tif !c.IsConnected() {\n\t\t\t// Attempt reconnect\n\t\t\tdelay := calculateBackoff(reconnectAttempt, c.reconnectBaseDelay, c.reconnectMaxDelay)\n\t\t\treconnectAttempt++\n\n\t\t\tselect {\n\t\t\tcase \u003c-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase \u003c-time.After(delay):\n\t\t\t}\n\n\t\t\tif err := c.reconnect(ctx); err != nil {\n\t\t\t\tif c.onError != nil {\n\t\t\t\t\tc.onError(fmt.Errorf(\"reconnect failed: %w\", err))\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treconnectAttempt = 0\n\t\t\tif c.onReconnect != nil {\n\t\t\t\tc.onReconnect()\n\t\t\t}\n\t\t}\n\n\t\t_, data, err := c.conn.Read(ctx)\n\t\tif err != nil {\n\t\t\tc.connected.Store(false)\n\t\t\tif c.onError != nil {\n\t\t\t\tc.onError(fmt.Errorf(\"read error: %w\", err))\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tmsg, err := ParseMessage(data)\n\t\tif err != nil {\n\t\t\tif c.onError != nil {\n\t\t\t\tc.onError(fmt.Errorf(\"parse error: %w\", err))\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tc.handleMessage(msg)\n\t}\n}\n\n// handleMessage processes an incoming message\nfunc (c *Client) handleMessage(msg *Message) {\n\t// Check if this is a response to a pending command\n\tif msg.ID > 0 {\n\t\tc.pendingMu.RLock()\n\t\trespChan, ok := c.pendingResponses[msg.ID]\n\t\tc.pendingMu.RUnlock()\n\n\t\tif ok {\n\t\t\tselect {\n\t\t\tcase respChan \u003c- msg:\n\t\t\tdefault:\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Route to channel handler\n\tif msg.Channel != \"\" {\n\t\tif err := c.router.Route(*msg); err != nil {\n\t\t\tif c.onError != nil {\n\t\t\t\tc.onError(fmt.Errorf(\"handler error: %w\", err))\n\t\t\t}\n\t\t}\n\t}\n}\n\n// pingLoop sends periodic ping messages\nfunc (c *Client) pingLoop(ctx context.Context) {\n\tdefer c.wg.Done()\n\n\tticker := time.NewTicker(c.pingInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\t\tcase \u003c-ticker.C:\n\t\t\tif !c.IsConnected() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tc.nextPingID++\n\t\t\tpingCmd := BuildPingCommand(c.nextPingID)\n\n\t\t\twriteCtx, cancel := context.WithTimeout(ctx, c.writeTimeout)\n\t\t\terr := c.sendCommand(writeCtx, pingCmd)\n\t\t\tcancel()\n\n\t\t\tif err != nil {\n\t\t\t\tif c.onError != nil {\n\t\t\t\t\tc.onError(fmt.Errorf(\"ping error: %w\", err))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// reconnect attempts to re-establish the connection\nfunc (c *Client) reconnect(ctx context.Context) error {\n\t// Store current subscriptions\n\tsubs := c.subscriptions.GetSubscriptions()\n\n\t// Clear subscription tracking (will be restored)\n\tc.subscriptions.Clear()\n\n\t// Reconnect\n\tif err := c.connect(ctx); err != nil {\n\t\treturn err\n\t}\n\n\t// Restore subscriptions\n\tcommands := c.subscriptions.RestoreSubscriptions(subs)\n\tfor _, cmd := range commands {\n\t\tif err := c.sendCommand(ctx, cmd); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to restore subscription: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// sendCommand sends a command to the WebSocket server\nfunc (c *Client) sendCommand(ctx context.Context, cmd *Command) error {\n\tdata, err := json.Marshal(cmd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal command: %w\", err)\n\t}\n\n\twriteCtx, cancel := context.WithTimeout(ctx, c.writeTimeout)\n\tdefer cancel()\n\n\treturn c.conn.Write(writeCtx, websocket.MessageText, data)\n}\n\n// Subscribe subscribes to a channel\nfunc (c *Client) Subscribe(ctx context.Context, channel Channel, params map[string]string) error {\n\tcmd, err := c.subscriptions.Subscribe(channel, params)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn c.sendCommand(ctx, cmd)\n}\n\n// Unsubscribe unsubscribes from a channel\nfunc (c *Client) Unsubscribe(ctx context.Context, channel Channel) error {\n\tcmd, err := c.subscriptions.Unsubscribe(channel)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn c.sendCommand(ctx, cmd)\n}\n\n// RegisterHandler registers a handler for a channel\nfunc (c *Client) RegisterHandler(channel Channel, handler Handler) {\n\tc.router.Register(channel, handler)\n}\n\n// UnregisterHandler removes a handler for a channel\nfunc (c *Client) UnregisterHandler(channel Channel) {\n\tc.router.Unregister(channel)\n}\n\n// IsConnected returns true if the client is connected\nfunc (c *Client) IsConnected() bool {\n\treturn c.connected.Load()\n}\n\n// Close closes the WebSocket connection\nfunc (c *Client) Close() {\n\tc.closeMu.Lock()\n\tif c.closed {\n\t\tc.closeMu.Unlock()\n\t\treturn\n\t}\n\tc.closed = true\n\tc.closeMu.Unlock()\n\n\tif c.cancelFunc != nil {\n\t\tc.cancelFunc()\n\t}\n\n\tc.connected.Store(false)\n\n\tif c.conn != nil {\n\t\tc.conn.Close(websocket.StatusNormalClosure, \"client closed\")\n\t}\n\n\tc.wg.Wait()\n}\n\n// OnReconnect sets a callback for when the client reconnects\nfunc (c *Client) OnReconnect(fn func()) {\n\tc.onReconnect = fn\n}\n\n// OnError sets a callback for error handling\nfunc (c *Client) OnError(fn func(error)) {\n\tc.onError = fn\n}\n\n// registerPendingResponse creates a channel to receive a response for a command\nfunc (c *Client) registerPendingResponse(id int) chan *Message {\n\tc.pendingMu.Lock()\n\tdefer c.pendingMu.Unlock()\n\n\tch := make(chan *Message, 1)\n\tc.pendingResponses[id] = ch\n\treturn ch\n}\n\n// unregisterPendingResponse removes a pending response channel\nfunc (c *Client) unregisterPendingResponse(id int) {\n\tc.pendingMu.Lock()\n\tdefer c.pendingMu.Unlock()\n\n\tdelete(c.pendingResponses, id)\n}\n\n// calculateBackoff calculates exponential backoff delay\nfunc calculateBackoff(attempt int, base, max time.Duration) time.Duration {\n\tdelay := base\n\tfor i := 0; i \u003c attempt; i++ {\n\t\tdelay *= 2\n\t\tif delay > max {\n\t\t\treturn max\n\t\t}\n\t}\n\treturn delay\n}\n\n// TickerData represents market ticker data\ntype TickerData struct {\n\tTicker string `json:\"ticker\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tYesBid int `json:\"yes_bid\"`\n\tYesAsk int `json:\"yes_ask\"`\n\tVolume int `json:\"volume\"`\n\tOpenInterest int `json:\"open_interest\"`\n}\n\n// OrderbookData represents orderbook data\ntype OrderbookData struct {\n\tTicker string `json:\"ticker\"`\n\tYesBids []OrderbookLevel `json:\"yes_bids\"`\n\tYesAsks []OrderbookLevel `json:\"yes_asks\"`\n\tNoBids []OrderbookLevel `json:\"no_bids\"`\n\tNoAsks []OrderbookLevel `json:\"no_asks\"`\n}\n\n// OrderbookLevel represents a price level\ntype OrderbookLevel struct {\n\tPrice int `json:\"price\"`\n\tQuantity int `json:\"quantity\"`\n}\n\n// TradeData represents trade data\ntype TradeData struct {\n\tTradeID string `json:\"trade_id\"`\n\tTicker string `json:\"ticker\"`\n\tPrice int `json:\"price\"`\n\tCount int `json:\"count\"`\n\tTakerSide string `json:\"taker_side\"`\n\tTimestamp string `json:\"ts\"`\n}\n\n// OrderUpdateData represents order update data\ntype OrderUpdateData struct {\n\tOrderID string `json:\"order_id\"`\n\tTicker string `json:\"ticker\"`\n\tStatus string `json:\"status\"`\n\tSide string `json:\"side\"`\n\tAction string `json:\"action\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tInitialQuantity int `json:\"initial_quantity\"`\n\tRemainingQuantity int `json:\"remaining_quantity\"`\n\tFilledQuantity int `json:\"filled_quantity\"`\n}\n\n// FillData represents fill data\ntype FillData struct {\n\tFillID string `json:\"fill_id\"`\n\tTradeID string `json:\"trade_id\"`\n\tOrderID string `json:\"order_id\"`\n\tTicker string `json:\"ticker\"`\n\tSide string `json:\"side\"`\n\tAction string `json:\"action\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tCount int `json:\"count\"`\n\tIsTaker bool `json:\"is_taker\"`\n\tTimestamp string `json:\"ts\"`\n}\n\n// PositionData represents position data\ntype PositionData struct {\n\tTicker string `json:\"ticker\"`\n\tPosition int `json:\"position\"`\n\tTotalCost int `json:\"total_cost\"`\n\tRealizedPnl int `json:\"realized_pnl\"`\n\tExposure int `json:\"exposure\"`\n}\n\n// TickerV2Data represents incremental delta ticker updates (market_ticker_v2)\ntype TickerV2Data struct {\n\tTicker string `json:\"ticker\"`\n\tDeltaType string `json:\"delta_type\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tDelta int `json:\"delta\"`\n\tVolume int `json:\"volume\"`\n}\n\n// OrderGroupUpdateData represents order group lifecycle updates\ntype OrderGroupUpdateData struct {\n\tOrderGroupID string `json:\"order_group_id\"`\n\tStatus string `json:\"status\"`\n\tTotalOrders int `json:\"total_orders\"`\n\tFilledOrders int `json:\"filled_orders\"`\n}\n\n// MarketLifecycleData represents market state changes\ntype MarketLifecycleData struct {\n\tTicker string `json:\"ticker\"`\n\tStatus string `json:\"status\"`\n\tOldStatus string `json:\"old_status\"`\n}\n\n// CommunicationData represents RFQ/quote notifications\ntype CommunicationData struct {\n\tType string `json:\"type\"`\n\tTicker string `json:\"ticker\"`\n\tQuantity int `json:\"quantity\"`\n\tPrice int `json:\"price\"`\n\tSide string `json:\"side\"`\n}\n\n// MultivariateLookupData represents collection lookup notifications\ntype MultivariateLookupData struct {\n\tSeriesID string `json:\"series_id\"`\n\tLookupValue string `json:\"lookup_value\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":13500,"content_sha256":"3c1ae72c6e69e8da9566876af433a35e9fedbe6e6a5cb4e9ec52d228b75e7e97"},{"filename":"internal/websocket/handlers_test.go","content":"package websocket\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestMessageHandler_HandleMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmessage Message\n\t\texpectCall bool\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname: \"market_ticker message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"ticker\",\n\t\t\t\tChannel: ChannelMarketTicker,\n\t\t\t\tData: json.RawMessage(`{\"ticker\":\"BTC-100K\",\"yes_price\":50}`),\n\t\t\t},\n\t\t\texpectCall: true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"orderbook message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"orderbook_snapshot\",\n\t\t\t\tChannel: ChannelOrderbook,\n\t\t\t\tData: json.RawMessage(`{\"ticker\":\"BTC-100K\"}`),\n\t\t\t},\n\t\t\texpectCall: true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"public_trades message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"trade\",\n\t\t\t\tChannel: ChannelPublicTrades,\n\t\t\t\tData: json.RawMessage(`{\"ticker\":\"BTC-100K\",\"price\":50}`),\n\t\t\t},\n\t\t\texpectCall: true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"user_orders message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"order_update\",\n\t\t\t\tChannel: ChannelUserOrders,\n\t\t\t\tData: json.RawMessage(`{\"order_id\":\"abc123\"}`),\n\t\t\t},\n\t\t\texpectCall: true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"user_fills message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"fill\",\n\t\t\t\tChannel: ChannelUserFills,\n\t\t\t\tData: json.RawMessage(`{\"fill_id\":\"xyz789\"}`),\n\t\t\t},\n\t\t\texpectCall: true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"unknown channel message\",\n\t\t\tmessage: Message{\n\t\t\t\tType: \"unknown\",\n\t\t\t\tChannel: \"unknown_channel\",\n\t\t\t\tData: json.RawMessage(`{}`),\n\t\t\t},\n\t\t\texpectCall: false,\n\t\t\texpectError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcalled := false\n\t\t\thandler := &MockHandler{\n\t\t\t\tonMessage: func(msg Message) error {\n\t\t\t\t\tcalled = true\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t}\n\n\t\t\trouter := NewMessageRouter()\n\t\t\tif tt.expectCall {\n\t\t\t\trouter.Register(tt.message.Channel, handler)\n\t\t\t}\n\n\t\t\terr := router.Route(tt.message)\n\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif tt.expectCall && !called {\n\t\t\t\tt.Error(\"expected handler to be called\")\n\t\t\t}\n\t\t\tif !tt.expectCall && called {\n\t\t\t\tt.Error(\"handler should not have been called\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMessageRouter_Register(t *testing.T) {\n\trouter := NewMessageRouter()\n\n\thandler := &MockHandler{}\n\trouter.Register(ChannelMarketTicker, handler)\n\n\tif len(router.handlers) != 1 {\n\t\tt.Errorf(\"expected 1 handler, got %d\", len(router.handlers))\n\t}\n\n\tif router.handlers[ChannelMarketTicker] != handler {\n\t\tt.Error(\"handler not registered correctly\")\n\t}\n}\n\nfunc TestMessageRouter_Unregister(t *testing.T) {\n\trouter := NewMessageRouter()\n\n\thandler := &MockHandler{}\n\trouter.Register(ChannelMarketTicker, handler)\n\trouter.Unregister(ChannelMarketTicker)\n\n\tif len(router.handlers) != 0 {\n\t\tt.Errorf(\"expected 0 handlers after unregister, got %d\", len(router.handlers))\n\t}\n}\n\nfunc TestParseMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tdata []byte\n\t\texpectError bool\n\t\tvalidate func(t *testing.T, msg *Message)\n\t}{\n\t\t{\n\t\t\tname: \"valid ticker message\",\n\t\t\tdata: []byte(`{\"type\":\"ticker\",\"channel\":\"ticker\",\"data\":{\"ticker\":\"BTC-100K\"}}`),\n\t\t\tvalidate: func(t *testing.T, msg *Message) {\n\t\t\t\tif msg.Type != \"ticker\" {\n\t\t\t\t\tt.Errorf(\"expected type 'ticker', got '%s'\", msg.Type)\n\t\t\t\t}\n\t\t\t\tif msg.Channel != ChannelMarketTicker {\n\t\t\t\t\tt.Errorf(\"expected channel '%s', got '%s'\", ChannelMarketTicker, msg.Channel)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valid command response\",\n\t\t\tdata: []byte(`{\"id\":1,\"type\":\"response\",\"msg\":{\"channels\":[\"ticker\"]}}`),\n\t\t\tvalidate: func(t *testing.T, msg *Message) {\n\t\t\t\tif msg.ID != 1 {\n\t\t\t\t\tt.Errorf(\"expected id 1, got %d\", msg.ID)\n\t\t\t\t}\n\t\t\t\tif msg.Type != \"response\" {\n\t\t\t\t\tt.Errorf(\"expected type 'response', got '%s'\", msg.Type)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid json\",\n\t\t\tdata: []byte(`{invalid`),\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"error response\",\n\t\t\tdata: []byte(`{\"id\":1,\"type\":\"error\",\"msg\":{\"error\":\"authentication failed\"}}`),\n\t\t\tvalidate: func(t *testing.T, msg *Message) {\n\t\t\t\tif msg.Type != \"error\" {\n\t\t\t\t\tt.Errorf(\"expected type 'error', got '%s'\", msg.Type)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmsg, err := ParseMessage(tt.data)\n\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif !tt.expectError && tt.validate != nil {\n\t\t\t\ttt.validate(t, msg)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// MockHandler implements Handler interface for testing\ntype MockHandler struct {\n\tonMessage func(msg Message) error\n}\n\nfunc (m *MockHandler) HandleMessage(msg Message) error {\n\tif m.onMessage != nil {\n\t\treturn m.onMessage(msg)\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4864,"content_sha256":"103c3f6466cb889f9ef6583d530bce75f92b89a77b3814dac633bccc56e892ab"},{"filename":"internal/websocket/handlers.go","content":"package websocket\n\nimport (\n\t\"encoding/json\"\n\t\"sync\"\n)\n\n// Channel represents a WebSocket channel type\ntype Channel string\n\nconst (\n\t// Public channels (no auth required)\n\tChannelMarketTicker Channel = \"ticker\"\n\tChannelMarketTickerV2 Channel = \"ticker_v2\"\n\tChannelPublicTrades Channel = \"trade\"\n\tChannelMarketLifecycle Channel = \"market_lifecycle_v2\"\n\tChannelMultivariateLookups Channel = \"multivariate\"\n\n\t// Authenticated channels\n\tChannelOrderbook Channel = \"orderbook_delta\"\n\tChannelUserOrders Channel = \"user_orders\"\n\tChannelUserFills Channel = \"fill\"\n\tChannelMarketPositions Channel = \"market_positions\"\n\tChannelOrderGroupUpdates Channel = \"order_group_updates\"\n\tChannelCommunications Channel = \"communications\"\n)\n\n// ChannelRequiresAuth returns true if the channel requires authentication\nfunc ChannelRequiresAuth(channel Channel) bool {\n\tswitch channel {\n\tcase ChannelOrderbook,\n\t\tChannelUserOrders,\n\t\tChannelUserFills,\n\t\tChannelMarketPositions,\n\t\tChannelOrderGroupUpdates,\n\t\tChannelCommunications:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// Message represents a WebSocket message from the server\ntype Message struct {\n\tID int `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tChannel Channel `json:\"channel,omitempty\"`\n\tMsg json.RawMessage `json:\"msg,omitempty\"`\n\tData json.RawMessage `json:\"data,omitempty\"`\n}\n\n// Handler defines the interface for handling WebSocket messages\ntype Handler interface {\n\tHandleMessage(msg Message) error\n}\n\n// HandlerFunc is a function adapter for Handler interface\ntype HandlerFunc func(msg Message) error\n\n// HandleMessage implements Handler interface\nfunc (f HandlerFunc) HandleMessage(msg Message) error {\n\treturn f(msg)\n}\n\n// MessageRouter routes messages to appropriate handlers by channel\ntype MessageRouter struct {\n\thandlers map[Channel]Handler\n\tmu sync.RWMutex\n}\n\n// NewMessageRouter creates a new message router\nfunc NewMessageRouter() *MessageRouter {\n\treturn &MessageRouter{\n\t\thandlers: make(map[Channel]Handler),\n\t}\n}\n\n// Register registers a handler for a specific channel\nfunc (r *MessageRouter) Register(channel Channel, handler Handler) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tr.handlers[channel] = handler\n}\n\n// Unregister removes a handler for a specific channel\nfunc (r *MessageRouter) Unregister(channel Channel) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tdelete(r.handlers, channel)\n}\n\n// Route routes a message to the appropriate handler\nfunc (r *MessageRouter) Route(msg Message) error {\n\tr.mu.RLock()\n\thandler, ok := r.handlers[msg.Channel]\n\tr.mu.RUnlock()\n\n\tif !ok {\n\t\t// No handler registered for this channel, ignore silently\n\t\treturn nil\n\t}\n\n\treturn handler.HandleMessage(msg)\n}\n\n// HasHandler returns true if a handler is registered for the channel\nfunc (r *MessageRouter) HasHandler(channel Channel) bool {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\t_, ok := r.handlers[channel]\n\treturn ok\n}\n\n// ParseMessage parses a raw JSON message into a Message struct\nfunc ParseMessage(data []byte) (*Message, error) {\n\tvar msg Message\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &msg, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3191,"content_sha256":"89b4b25fc4c1b15a94dab060b9b55b16847165f641a004d76cf336484fae7ca0"},{"filename":"pkg/models/apikey.go","content":"package models\n\nimport \"time\"\n\n// APIKey represents an API key\ntype APIKey struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n\tExpiresTime time.Time `json:\"expires_time,omitempty\"`\n\tScopes []string `json:\"scopes\"`\n}\n\n// APIKeysResponse is the API response for API keys\ntype APIKeysResponse struct {\n\tAPIKeys []APIKey `json:\"api_keys\"`\n}\n\n// CreateAPIKeyRequest is the request to create an API key\ntype CreateAPIKeyRequest struct {\n\tName string `json:\"name,omitempty\"`\n}\n\n// CreateAPIKeyResponse is the response from creating an API key\ntype CreateAPIKeyResponse struct {\n\tAPIKey APIKey `json:\"api_key\"`\n\tPrivateKey string `json:\"private_key\"`\n}\n\n// CreateAPIKeyWithPublicKeyRequest creates API key with user's public key\ntype CreateAPIKeyWithPublicKeyRequest struct {\n\tName string `json:\"name,omitempty\"`\n\tPublicKey string `json:\"public_key\"`\n}\n\n// CreateAPIKeyWithPublicKeyResponse is the response\ntype CreateAPIKeyWithPublicKeyResponse struct {\n\tAPIKey APIKey `json:\"api_key\"`\n}\n\n// APILimits represents account API limits\ntype APILimits struct {\n\tRateLimit int `json:\"rate_limit\"`\n\tMaxOrdersPerCall int `json:\"max_orders_per_call\"`\n}\n\n// APILimitsResponse is the response for API limits\ntype APILimitsResponse struct {\n\tAPILimits APILimits `json:\"api_limits\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1362,"content_sha256":"63e611b2d6e4def5f456519ab1f4a6a53f273bab6dfdf326a045acb06618bb58"},{"filename":"pkg/models/event.go","content":"package models\n\nimport \"time\"\n\n// Event represents a Kalshi event\ntype Event struct {\n\tEventTicker string `json:\"event_ticker\"`\n\tSeriesTicker string `json:\"series_ticker\"`\n\tTitle string `json:\"title\"`\n\tSubTitle string `json:\"sub_title\"`\n\tCategory string `json:\"category\"`\n\tMutuallyExclusive bool `json:\"mutually_exclusive\"`\n\tCollateralReturnType string `json:\"collateral_return_type\"`\n\tStrikeDate *time.Time `json:\"strike_date,omitempty\"`\n\tStrikePeriod string `json:\"strike_period,omitempty\"`\n\tAvailableOnBrokers bool `json:\"available_on_brokers\"`\n\tMarkets []string `json:\"markets,omitempty\"`\n}\n\n// EventResponse is the API response for a single event\ntype EventResponse struct {\n\tEvent Event `json:\"event\"`\n}\n\n// EventsResponse is the API response for multiple events\ntype EventsResponse struct {\n\tEvents []Event `json:\"events\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// EventMetadata contains additional event information\ntype EventMetadata struct {\n\tEventTicker string `json:\"event_ticker\"`\n\tMetadata map[string]string `json:\"metadata\"`\n}\n\n// EventMetadataResponse is the API response for event metadata\ntype EventMetadataResponse struct {\n\tEventMetadata EventMetadata `json:\"event_metadata\"`\n}\n\n// ForecastPercentilePoint represents a single point in forecast history\ntype ForecastPercentilePoint struct {\n\tTimestamp time.Time `json:\"timestamp\"`\n\tP10 int `json:\"p10\"`\n\tP25 int `json:\"p25\"`\n\tP50 int `json:\"p50\"`\n\tP75 int `json:\"p75\"`\n\tP90 int `json:\"p90\"`\n}\n\n// ForecastPercentileHistoryResponse is the API response for forecast history\ntype ForecastPercentileHistoryResponse struct {\n\tHistory []ForecastPercentilePoint `json:\"history\"`\n}\n\n// MultivariateEvent represents a multivariate event\ntype MultivariateEvent struct {\n\tTicker string `json:\"ticker\"`\n\tTitle string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tStatus string `json:\"status\"`\n\tLookupTable []string `json:\"lookup_table\"`\n\tLookupType string `json:\"lookup_type\"`\n}\n\n// MultivariateEventsResponse is the API response for multivariate events\ntype MultivariateEventsResponse struct {\n\tEvents []MultivariateEvent `json:\"multivariate_events\"`\n\tCursor string `json:\"cursor\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2397,"content_sha256":"edee3996d25dfb96e3287abfd6d348100ea71f1f764bf1bb6dcfbbd825abb42d"},{"filename":"pkg/models/exchange.go","content":"package models\n\nimport \"time\"\n\n// ExchangeStatusResponse is the API response for exchange status\ntype ExchangeStatusResponse struct {\n\tExchangeActive bool `json:\"exchange_active\"`\n\tTradingActive bool `json:\"trading_active\"`\n}\n\n// ExchangeSchedule represents the exchange schedule\ntype ExchangeSchedule struct {\n\tStandardHours []WeeklySchedule `json:\"standard_hours\"`\n\tMaintenanceWindows []MaintenanceWindow `json:\"maintenance_windows\"`\n}\n\n// WeeklySchedule represents a weekly schedule block\ntype WeeklySchedule struct {\n\tStartTime string `json:\"start_time\"`\n\tEndTime string `json:\"end_time\"`\n\tMonday []DailySchedule `json:\"monday\"`\n\tTuesday []DailySchedule `json:\"tuesday\"`\n\tWednesday []DailySchedule `json:\"wednesday\"`\n\tThursday []DailySchedule `json:\"thursday\"`\n\tFriday []DailySchedule `json:\"friday\"`\n\tSaturday []DailySchedule `json:\"saturday\"`\n\tSunday []DailySchedule `json:\"sunday\"`\n}\n\n// DailySchedule represents open/close times for a day\ntype DailySchedule struct {\n\tOpenTime string `json:\"open_time\"`\n\tCloseTime string `json:\"close_time\"`\n}\n\n// MaintenanceWindow represents a scheduled maintenance window\ntype MaintenanceWindow struct {\n\tStartDatetime string `json:\"start_datetime\"`\n\tEndDatetime string `json:\"end_datetime\"`\n}\n\n// ExchangeScheduleResponse is the API response for schedule\ntype ExchangeScheduleResponse struct {\n\tSchedule ExchangeSchedule `json:\"schedule\"`\n}\n\n// Announcement represents an exchange announcement\ntype Announcement struct {\n\tID string `json:\"id\"`\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tStatus string `json:\"status\"`\n\tType string `json:\"type\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n\tDeliveryTime time.Time `json:\"delivery_time\"`\n}\n\n// AnnouncementsResponse is the API response for announcements\ntype AnnouncementsResponse struct {\n\tAnnouncements []Announcement `json:\"announcements\"`\n}\n\n// FeeChange represents a fee change (deprecated - use SeriesFeeChange)\ntype FeeChange struct {\n\tTicker string `json:\"ticker\"`\n\tOldFee int `json:\"old_fee\"`\n\tNewFee int `json:\"new_fee\"`\n\tEffectiveAt time.Time `json:\"effective_at\"`\n}\n\n// FeeChangesResponse is the API response for fee changes (deprecated - use SeriesFeeChangesResponse)\ntype FeeChangesResponse struct {\n\tFeeChanges []FeeChange `json:\"fee_changes\"`\n}\n\n// SeriesFeeChange represents a fee change for a series per Kalshi API spec\ntype SeriesFeeChange struct {\n\tSeriesTicker string `json:\"series_ticker\"`\n\tOldFeeRate float64 `json:\"old_fee_rate\"`\n\tNewFeeRate float64 `json:\"new_fee_rate\"`\n\tEffectiveDate time.Time `json:\"effective_date\"`\n\tAnnouncedDate time.Time `json:\"announced_date\"`\n}\n\n// SeriesFeeChangesResponse is the API response for series fee changes\ntype SeriesFeeChangesResponse struct {\n\tSeriesFeeChanges []SeriesFeeChange `json:\"series_fee_changes\"`\n}\n\n// UserDataTimestampResponse is the API response for user data timestamp\ntype UserDataTimestampResponse struct {\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3082,"content_sha256":"b20a1ed3fca3a4c875b158d2e63708c2b83c20dbd891e07c9bac32c537af9fb6"},{"filename":"pkg/models/market.go","content":"package models\n\nimport (\n\t\"encoding/json\"\n\t\"time\"\n)\n\n// Market represents a Kalshi prediction market\ntype Market struct {\n\tTicker string `json:\"ticker\"`\n\tEventTicker string `json:\"event_ticker\"`\n\tMarketType string `json:\"market_type\"`\n\tTitle string `json:\"title\"`\n\tSubtitle string `json:\"subtitle\"`\n\tStatus string `json:\"status\"`\n\tYesBid int `json:\"yes_bid\"`\n\tYesAsk int `json:\"yes_ask\"`\n\tNoBid int `json:\"no_bid\"`\n\tNoAsk int `json:\"no_ask\"`\n\tLastPrice int `json:\"last_price\"`\n\tPreviousYesBid int `json:\"previous_yes_bid\"`\n\tPreviousYesAsk int `json:\"previous_yes_ask\"`\n\tPreviousPrice int `json:\"previous_price\"`\n\tVolume int `json:\"volume\"`\n\tVolume24H int `json:\"volume_24h\"`\n\tOpenInterest int `json:\"open_interest\"`\n\tDollarVolume int `json:\"dollar_volume\"`\n\tDollarOpenInterest int `json:\"dollar_open_interest\"`\n\tResult string `json:\"result\"`\n\tExpirationTime time.Time `json:\"expiration_time\"`\n\tLatestExpirationTime time.Time `json:\"latest_expiration_time\"`\n\tCloseTime time.Time `json:\"close_time\"`\n\tOpenTime time.Time `json:\"open_time\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n\tCanCloseEarly bool `json:\"can_close_early\"`\n\tRiskLimitCents int `json:\"risk_limit_cents\"`\n\tNotionalValue int `json:\"notional_value\"`\n\tTickSize int `json:\"tick_size\"`\n\tYesBidFee int `json:\"yes_bid_fee\"`\n\tNoBidFee int `json:\"no_bid_fee\"`\n\tYesAskFee int `json:\"yes_ask_fee\"`\n\tNoAskFee int `json:\"no_ask_fee\"`\n\tCategory string `json:\"category\"`\n\tRules string `json:\"rules\"`\n\tRulesSecondary string `json:\"rules_secondary\"`\n\tSettlementTimerSeconds int `json:\"settlement_timer_seconds\"`\n}\n\n// MarketResponse is the API response for a single market\ntype MarketResponse struct {\n\tMarket Market `json:\"market\"`\n}\n\n// MarketsResponse is the API response for multiple markets\ntype MarketsResponse struct {\n\tMarkets []Market `json:\"markets\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// Orderbook represents a market orderbook\ntype Orderbook struct {\n\tTicker string `json:\"ticker\"`\n\tYesBids []OrderbookLevel `json:\"yes_bids\"`\n\tYesAsks []OrderbookLevel `json:\"yes_asks\"`\n\tNoBids []OrderbookLevel `json:\"no_bids\"`\n\tNoAsks []OrderbookLevel `json:\"no_asks\"`\n}\n\n// OrderbookLevel represents a single price level in the orderbook\ntype OrderbookLevel struct {\n\tPrice int `json:\"price\"`\n\tQuantity int `json:\"quantity\"`\n}\n\n// OrderbookResponse is the API response for orderbook\ntype OrderbookResponse struct {\n\tOrderbook Orderbook `json:\"orderbook\"`\n}\n\n// Trade represents a public trade\ntype Trade struct {\n\tTradeID string `json:\"trade_id\"`\n\tTicker string `json:\"ticker\"`\n\tPrice int `json:\"price\"`\n\tCount int `json:\"count\"`\n\tTakerSide string `json:\"taker_side\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n}\n\n// TradesResponse is the API response for trades\ntype TradesResponse struct {\n\tTrades []Trade `json:\"trades\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// Candlestick represents OHLCV data.\n// The Kalshi v2 API returns candlesticks with a nested \"price\" object\n// and \"end_period_ts\" as a unix timestamp. UnmarshalJSON handles this.\ntype Candlestick struct {\n\tTicker string `json:\"-\"`\n\tOpen int `json:\"-\"`\n\tHigh int `json:\"-\"`\n\tLow int `json:\"-\"`\n\tClose int `json:\"-\"`\n\tVolume int `json:\"volume\"`\n\tOpenInterest int `json:\"open_interest\"`\n\tPeriodEnd time.Time `json:\"-\"`\n}\n\n// candlestickJSON is the structure used for JSON output (--json flag)\ntype candlestickJSON struct {\n\tTicker string `json:\"ticker,omitempty\"`\n\tOpen int `json:\"open\"`\n\tHigh int `json:\"high\"`\n\tLow int `json:\"low\"`\n\tClose int `json:\"close\"`\n\tVolume int `json:\"volume\"`\n\tOpenInterest int `json:\"open_interest\"`\n\tPeriodEnd string `json:\"period_end,omitempty\"`\n}\n\n// MarshalJSON implements json.Marshaler for Candlestick.\n// Produces clean JSON output for --json flag.\nfunc (c Candlestick) MarshalJSON() ([]byte, error) {\n\tout := candlestickJSON{\n\t\tTicker: c.Ticker,\n\t\tOpen: c.Open,\n\t\tHigh: c.High,\n\t\tLow: c.Low,\n\t\tClose: c.Close,\n\t\tVolume: c.Volume,\n\t\tOpenInterest: c.OpenInterest,\n\t}\n\tif !c.PeriodEnd.IsZero() {\n\t\tout.PeriodEnd = c.PeriodEnd.Format(time.RFC3339)\n\t}\n\treturn json.Marshal(out)\n}\n\n// candlestickWire is the raw Kalshi v2 API wire format\ntype candlestickWire struct {\n\tEndPeriodTs int64 `json:\"end_period_ts\"`\n\tPrice struct {\n\t\tOpen int `json:\"open\"`\n\t\tHigh int `json:\"high\"`\n\t\tLow int `json:\"low\"`\n\t\tClose int `json:\"close\"`\n\t} `json:\"price\"`\n\tVolume int `json:\"volume\"`\n\tOpenInterest int `json:\"open_interest\"`\n}\n\n// UnmarshalJSON implements json.Unmarshaler for Candlestick.\n// Extracts OHLC from the nested \"price\" object and converts end_period_ts.\nfunc (c *Candlestick) UnmarshalJSON(data []byte) error {\n\tvar w candlestickWire\n\tif err := json.Unmarshal(data, &w); err != nil {\n\t\treturn err\n\t}\n\n\tc.Open = w.Price.Open\n\tc.High = w.Price.High\n\tc.Low = w.Price.Low\n\tc.Close = w.Price.Close\n\tc.Volume = w.Volume\n\tc.OpenInterest = w.OpenInterest\n\n\tif w.EndPeriodTs > 0 {\n\t\tc.PeriodEnd = time.Unix(w.EndPeriodTs, 0).UTC()\n\t}\n\n\treturn nil\n}\n\n// CandlesticksResponse is the API response for market candlesticks\ntype CandlesticksResponse struct {\n\tCandlesticks []Candlestick `json:\"candlesticks\"`\n}\n\n// EventCandlesticksResponse is the API response for event candlesticks.\n// The event endpoint returns market_tickers + market_candlesticks (array of arrays).\ntype EventCandlesticksResponse struct {\n\tMarketTickers []string `json:\"market_tickers\"`\n\tMarketCandlesticks [][]Candlestick `json:\"market_candlesticks\"`\n\tAdjustedEndTs int64 `json:\"adjusted_end_ts,omitempty\"`\n}\n\n// AllCandlesticks flattens all markets' candlesticks into a single slice,\n// setting the Ticker field from the corresponding market_tickers entry.\nfunc (r *EventCandlesticksResponse) AllCandlesticks() []Candlestick {\n\tvar result []Candlestick\n\tfor i, marketCandles := range r.MarketCandlesticks {\n\t\tticker := \"\"\n\t\tif i \u003c len(r.MarketTickers) {\n\t\t\tticker = r.MarketTickers[i]\n\t\t}\n\t\tfor _, c := range marketCandles {\n\t\t\tc.Ticker = ticker\n\t\t\tresult = append(result, c)\n\t\t}\n\t}\n\treturn result\n}\n\n// Series represents a market series\ntype Series struct {\n\tTicker string `json:\"ticker\"`\n\tTitle string `json:\"title\"`\n\tCategory string `json:\"category\"`\n\tFrequency string `json:\"frequency\"`\n\tTags []string `json:\"tags\"`\n}\n\n// SeriesResponse is the API response for series\ntype SeriesResponse struct {\n\tSeries []Series `json:\"series\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// MarketCandlesticks represents candlestick data for a single market in batch response\ntype MarketCandlesticks struct {\n\tTicker string `json:\"ticker\"`\n\tCandlesticks []Candlestick `json:\"candlesticks\"`\n}\n\n// BatchCandlesticksResponse is the API response for batch candlesticks\ntype BatchCandlesticksResponse struct {\n\tMarketCandlesticks []MarketCandlesticks `json:\"market_candlesticks\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7487,"content_sha256":"90f8bc3ff36adc70132279e81563b67b2e19ecf9f904cd39ecff8b2381f9e17c"},{"filename":"pkg/models/milestone.go","content":"package models\n\nimport \"time\"\n\n// Milestone represents a milestone in the Kalshi system\ntype Milestone struct {\n\tID string `json:\"id\"`\n\tTitle string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tStatus string `json:\"status\"`\n\tCategory string `json:\"category\"`\n\tTargetDate time.Time `json:\"target_date\"`\n\tResolutionDate time.Time `json:\"resolution_date,omitempty\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n}\n\n// MilestoneResponse is the API response for a single milestone\ntype MilestoneResponse struct {\n\tMilestone Milestone `json:\"milestone\"`\n}\n\n// MilestonesResponse is the API response for multiple milestones\ntype MilestonesResponse struct {\n\tMilestones []Milestone `json:\"milestones\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\n// LiveData represents current data for a milestone\ntype LiveData struct {\n\tMilestoneID string `json:\"milestone_id\"`\n\tValue float64 `json:\"value\"`\n\tUnit string `json:\"unit\"`\n\tSource string `json:\"source\"`\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\n// LiveDataResponse is the API response for live data\ntype LiveDataResponse struct {\n\tData LiveData `json:\"live_data\"`\n}\n\n// BatchLiveDataResponse is the API response for multiple live data items\ntype BatchLiveDataResponse struct {\n\tData []LiveData `json:\"live_data\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1367,"content_sha256":"de6fec0a6460e885c5367d61a052f30c26e001ef059f11183ca907fc3f740871"},{"filename":"pkg/models/multivariate.go","content":"package models\n\nimport \"time\"\n\n// MultivariateCollection represents a multivariate collection\ntype MultivariateCollection struct {\n\tTicker string `json:\"ticker\"`\n\tTitle string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tStatus string `json:\"status\"`\n\tLookupType string `json:\"lookup_type\"`\n\tLookupTable []string `json:\"lookup_table,omitempty\"`\n\tCategoryPath []string `json:\"category_path,omitempty\"`\n}\n\n// MultivariateCollectionsResponse is the API response for multiple collections\ntype MultivariateCollectionsResponse struct {\n\tCollections []MultivariateCollection `json:\"multivariate_collections\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\n// MultivariateCollectionResponse is the API response for a single collection\ntype MultivariateCollectionResponse struct {\n\tCollection MultivariateCollection `json:\"multivariate_collection\"`\n}\n\n// LookupHistoryEntry represents a single lookup history entry\ntype LookupHistoryEntry struct {\n\tTicker string `json:\"ticker\"`\n\tLookupValue string `json:\"lookup_value\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n}\n\n// LookupHistoryResponse is the API response for lookup history\ntype LookupHistoryResponse struct {\n\tHistory []LookupHistoryEntry `json:\"history\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\n// CreateCollectionMarketResponse is the API response for creating a market\ntype CreateCollectionMarketResponse struct {\n\tMarketTicker string `json:\"market_ticker\"`\n\tCreated bool `json:\"created\"`\n}\n\n// LookupCollectionMarketResponse is the API response for looking up a market\ntype LookupCollectionMarketResponse struct {\n\tMarketTicker string `json:\"market_ticker\"`\n\tFound bool `json:\"found\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1753,"content_sha256":"23f1d217334f59ed25f7fe5afd159541c26e07da3a30f089d784a60f41f2ec0e"},{"filename":"pkg/models/order.go","content":"package models\n\nimport \"time\"\n\n// OrderSide represents buy/sell side\ntype OrderSide string\n\nconst (\n\tOrderSideYes OrderSide = \"yes\"\n\tOrderSideNo OrderSide = \"no\"\n)\n\n// OrderType represents order type\ntype OrderType string\n\nconst (\n\tOrderTypeLimit OrderType = \"limit\"\n\tOrderTypeMarket OrderType = \"market\"\n)\n\n// OrderStatus represents order status\ntype OrderStatus string\n\nconst (\n\tOrderStatusResting OrderStatus = \"resting\"\n\tOrderStatusCanceled OrderStatus = \"canceled\"\n\tOrderStatusExecuted OrderStatus = \"executed\"\n\tOrderStatusPending OrderStatus = \"pending\"\n)\n\n// OrderAction represents order action\ntype OrderAction string\n\nconst (\n\tOrderActionBuy OrderAction = \"buy\"\n\tOrderActionSell OrderAction = \"sell\"\n)\n\n// Order represents a trading order\ntype Order struct {\n\tOrderID string `json:\"order_id\"`\n\tUserID string `json:\"user_id\"`\n\tTicker string `json:\"ticker\"`\n\tStatus OrderStatus `json:\"status\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tType OrderType `json:\"type\"`\n\tSide OrderSide `json:\"side\"`\n\tAction OrderAction `json:\"action\"`\n\tInitialCount int `json:\"initial_count\"`\n\tRemainingCount int `json:\"remaining_count\"`\n\tFillCount int `json:\"fill_count\"`\n\tQueuePosition int `json:\"queue_position\"`\n\tCancelOrderOnPause bool `json:\"cancel_order_on_pause\"`\n\tExpirationTime *time.Time `json:\"expiration_time,omitempty\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n\tLastUpdateTime time.Time `json:\"last_update_time\"`\n\tOrderGroupID string `json:\"order_group_id,omitempty\"`\n\tTakerFillCount int `json:\"taker_fill_count\"`\n\tTakerFillCost int `json:\"taker_fill_cost\"`\n\tTakerFees int `json:\"taker_fees\"`\n\tMakerFillCount int `json:\"maker_fill_count\"`\n\tMakerFillCost int `json:\"maker_fill_cost\"`\n\tMakerFees int `json:\"maker_fees\"`\n\tClientOrderID string `json:\"client_order_id,omitempty\"`\n\tSubaccountNumber int `json:\"subaccount_number,omitempty\"`\n\tSelfTradePreventionType string `json:\"self_trade_prevention_type,omitempty\"`\n}\n\n// OrderResponse is the API response for a single order\ntype OrderResponse struct {\n\tOrder Order `json:\"order\"`\n}\n\n// OrdersResponse is the API response for multiple orders\ntype OrdersResponse struct {\n\tOrders []Order `json:\"orders\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// CreateOrderRequest is the request to create an order\ntype CreateOrderRequest struct {\n\tTicker string `json:\"ticker\"`\n\tSide OrderSide `json:\"side\"`\n\tAction OrderAction `json:\"action\"`\n\tType OrderType `json:\"type\"`\n\tCount int `json:\"count\"`\n\tYesPrice int `json:\"yes_price,omitempty\"`\n\tNoPrice int `json:\"no_price,omitempty\"`\n\tExpirationTs int64 `json:\"expiration_ts,omitempty\"`\n\tClientOrderID string `json:\"client_order_id,omitempty\"`\n\tOrderGroupID string `json:\"order_group_id,omitempty\"`\n\tSubaccountID int `json:\"subaccount_id,omitempty\"`\n\tSellPositionFloor int `json:\"sell_position_floor,omitempty\"`\n\tBuyMaxCost int `json:\"buy_max_cost,omitempty\"`\n}\n\n// CreateOrderResponse is the response from creating an order\ntype CreateOrderResponse struct {\n\tOrder Order `json:\"order\"`\n}\n\n// AmendOrderRequest is the request to amend an order\ntype AmendOrderRequest struct {\n\tPrice int `json:\"price,omitempty\"`\n\tCount int `json:\"count,omitempty\"`\n}\n\n// DecreaseOrderRequest is the request to decrease an order\ntype DecreaseOrderRequest struct {\n\tReduceBy int `json:\"reduce_by\"`\n}\n\n// BatchCreateOrdersRequest is for batch order creation\ntype BatchCreateOrdersRequest struct {\n\tOrders []CreateOrderRequest `json:\"orders\"`\n}\n\n// BatchCreateOrdersResponse is the response from batch order creation\ntype BatchCreateOrdersResponse struct {\n\tOrders []Order `json:\"orders\"`\n}\n\n// BatchCancelOrdersRequest is for batch order cancellation\ntype BatchCancelOrdersRequest struct {\n\tOrderIDs []string `json:\"order_ids,omitempty\"`\n\tTicker string `json:\"ticker,omitempty\"`\n}\n\n// BatchCancelOrdersResponse is the response from batch cancellation\ntype BatchCancelOrdersResponse struct {\n\tOrders []Order `json:\"orders\"`\n}\n\n// QueuePosition represents an order's queue position\ntype QueuePosition struct {\n\tOrderID string `json:\"order_id\"`\n\tQueuePosition int `json:\"queue_position\"`\n}\n\n// QueuePositionsResponse is the response for queue positions\ntype QueuePositionsResponse struct {\n\tPositions []QueuePosition `json:\"queue_positions\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4896,"content_sha256":"1bf0e3659dd35137cf6bd24fe88bf5754e309f8de1e5bd070d31beeb2a20e65f"},{"filename":"pkg/models/ordergroup.go","content":"package models\n\nimport \"time\"\n\n// OrderGroup represents an order group\ntype OrderGroup struct {\n\tGroupID string `json:\"order_group_id\"`\n\tStatus string `json:\"status\"`\n\tLimit int `json:\"limit\"`\n\tFilledCount int `json:\"filled_count\"`\n\tOrderCount int `json:\"order_count\"`\n\tOrderIDs []string `json:\"order_ids\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n\tLastUpdateTime time.Time `json:\"last_update_time\"`\n}\n\n// OrderGroupsResponse is the API response for order groups\ntype OrderGroupsResponse struct {\n\tOrderGroups []OrderGroup `json:\"order_groups\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// OrderGroupResponse is the API response for a single order group\ntype OrderGroupResponse struct {\n\tOrderGroup OrderGroup `json:\"order_group\"`\n}\n\n// CreateOrderGroupRequest is the request to create an order group\ntype CreateOrderGroupRequest struct {\n\tLimit int `json:\"limit\"`\n}\n\n// CreateOrderGroupResponse is the response from creating an order group\ntype CreateOrderGroupResponse struct {\n\tOrderGroup OrderGroup `json:\"order_group\"`\n}\n\n// UpdateOrderGroupLimitRequest is the request to update order group limit\ntype UpdateOrderGroupLimitRequest struct {\n\tLimit int `json:\"limit\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1253,"content_sha256":"0de5895fdf48c757609a16671cbf127f0dd08a223b99661798230c1eef0d0e42"},{"filename":"pkg/models/position.go","content":"package models\n\nimport \"time\"\n\n// MarketPosition represents a market position from the API\ntype MarketPosition struct {\n\tTicker string `json:\"ticker\"`\n\tTotalTraded int `json:\"total_traded\"`\n\tTotalTradedDollars string `json:\"total_traded_dollars\"`\n\tPosition int `json:\"position\"`\n\tPositionFP string `json:\"position_fp\"`\n\tMarketExposure int `json:\"market_exposure\"`\n\tMarketExposureDollars string `json:\"market_exposure_dollars\"`\n\tRealizedPnl int `json:\"realized_pnl\"`\n\tRealizedPnlDollars string `json:\"realized_pnl_dollars\"`\n\tRestingOrdersCount int `json:\"resting_orders_count\"`\n\tFeesPaid int `json:\"fees_paid\"`\n\tFeesPaidDollars string `json:\"fees_paid_dollars\"`\n\tLastUpdatedTs string `json:\"last_updated_ts\"`\n}\n\n// Position is an alias for MarketPosition for backward compatibility\ntype Position = MarketPosition\n\n// EventPosition represents an event-level position from the API\ntype EventPosition struct {\n\tEventTicker string `json:\"event_ticker\"`\n\tTotalCost int `json:\"total_cost\"`\n\tTotalCostDollars string `json:\"total_cost_dollars\"`\n\tTotalCostShares int `json:\"total_cost_shares\"`\n\tTotalCostSharesFP string `json:\"total_cost_shares_fp\"`\n\tEventExposure int `json:\"event_exposure\"`\n\tEventExposureDollars string `json:\"event_exposure_dollars\"`\n\tRealizedPnl int `json:\"realized_pnl\"`\n\tRealizedPnlDollars string `json:\"realized_pnl_dollars\"`\n\tFeesPaid int `json:\"fees_paid\"`\n\tFeesPaidDollars string `json:\"fees_paid_dollars\"`\n}\n\n// PositionsResponse is the API response for positions\ntype PositionsResponse struct {\n\tPositions []MarketPosition `json:\"market_positions\"`\n\tEventPositions []EventPosition `json:\"event_positions,omitempty\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// BalanceResponse is the API response for balance\ntype BalanceResponse struct {\n\tBalance int `json:\"balance\"`\n\tPortfolioValue int `json:\"portfolio_value\"`\n\tUpdatedTs int64 `json:\"updated_ts\"`\n}\n\n// Fill represents a trade fill\ntype Fill struct {\n\tTradeID string `json:\"trade_id\"`\n\tOrderID string `json:\"order_id\"`\n\tTicker string `json:\"ticker\"`\n\tSide string `json:\"side\"`\n\tAction string `json:\"action\"`\n\tType string `json:\"type\"`\n\tYesPrice int `json:\"yes_price\"`\n\tNoPrice int `json:\"no_price\"`\n\tCount int `json:\"count\"`\n\tIsTaker bool `json:\"is_taker\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n}\n\n// FillsResponse is the API response for fills\ntype FillsResponse struct {\n\tFills []Fill `json:\"fills\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// Settlement represents a market settlement\ntype Settlement struct {\n\tTicker string `json:\"ticker\"`\n\tMarketResult string `json:\"market_result\"`\n\tNoTotalCost int `json:\"no_total_cost\"`\n\tYesTotalCost int `json:\"yes_total_cost\"`\n\tNoCount int `json:\"no_count\"`\n\tYesCount int `json:\"yes_count\"`\n\tRevenue int `json:\"revenue\"`\n\tSettledTime time.Time `json:\"settled_time\"`\n}\n\n// SettlementsResponse is the API response for settlements\ntype SettlementsResponse struct {\n\tSettlements []Settlement `json:\"settlements\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// Subaccount represents a subaccount\ntype Subaccount struct {\n\tSubaccountID int `json:\"subaccount_id\"`\n\tBalance int `json:\"balance\"`\n\tAvailableBalance int `json:\"available_balance\"`\n}\n\n// SubaccountsResponse is the API response for subaccounts\ntype SubaccountsResponse struct {\n\tSubaccounts []Subaccount `json:\"subaccounts\"`\n}\n\n// Transfer represents a subaccount transfer\ntype Transfer struct {\n\tTransferID string `json:\"transfer_id\"`\n\tFromSubaccount int `json:\"from_subaccount\"`\n\tToSubaccount int `json:\"to_subaccount\"`\n\tAmount int `json:\"amount\"`\n\tCreatedTime time.Time `json:\"created_time\"`\n}\n\n// TransfersResponse is the API response for transfers\ntype TransfersResponse struct {\n\tTransfers []Transfer `json:\"transfers\"`\n}\n\n// TransferRequest is the request to transfer between subaccounts\ntype TransferRequest struct {\n\tFromSubaccount int `json:\"from_subaccount_id\"`\n\tToSubaccount int `json:\"to_subaccount_id\"`\n\tAmount int `json:\"amount\"`\n}\n\n// SubaccountBalance represents a subaccount balance entry\ntype SubaccountBalance struct {\n\tSubaccountID int `json:\"subaccount_id\"`\n\tBalance int `json:\"balance\"`\n\tAvailableBalance int `json:\"available_balance\"`\n}\n\n// SubaccountBalancesResponse is the API response for subaccount balances\ntype SubaccountBalancesResponse struct {\n\tBalances []SubaccountBalance `json:\"balances\"`\n}\n\n// RestingOrderValueResponse is the API response for resting order value\ntype RestingOrderValueResponse struct {\n\tRestingOrderValue int `json:\"resting_order_value\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4923,"content_sha256":"5ac9ca719da4c2b5f5b63f2db77588e2b5fef23533e318df8a895ef813c117dd"},{"filename":"pkg/models/rfq.go","content":"package models\n\n// RFQ represents a Request for Quote\ntype RFQ struct {\n\tID string `json:\"id\"`\n\tCreatorID string `json:\"creator_id\"`\n\tMarketTicker string `json:\"market_ticker\"`\n\tContracts int `json:\"contracts\"`\n\tContractsFP string `json:\"contracts_fp\"`\n\tTargetCostDollars string `json:\"target_cost_dollars\"`\n\tStatus string `json:\"status\"`\n\tCreatedTs string `json:\"created_ts\"`\n\tUpdatedTs string `json:\"updated_ts\"`\n\tMveCollectionTicker string `json:\"mve_collection_ticker,omitempty\"`\n\tRestRemainder bool `json:\"rest_remainder\"`\n\tCancellationReason string `json:\"cancellation_reason,omitempty\"`\n\tCreatorUserID string `json:\"creator_user_id\"`\n\tCancelledTs string `json:\"cancelled_ts,omitempty\"`\n}\n\n// RFQsResponse is the API response for RFQs\ntype RFQsResponse struct {\n\tRFQs []RFQ `json:\"rfqs\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// RFQResponse is the API response for a single RFQ\ntype RFQResponse struct {\n\tRFQ RFQ `json:\"rfq\"`\n}\n\n// CreateRFQRequest is the request to create an RFQ\ntype CreateRFQRequest struct {\n\tMarketTicker string `json:\"market_ticker\"`\n\tContracts int `json:\"contracts\"`\n}\n\n// Quote represents a quote on an RFQ\ntype Quote struct {\n\tID string `json:\"id\"`\n\tRFQID string `json:\"rfq_id\"`\n\tCreatorID string `json:\"creator_id\"`\n\tRFQCreatorID string `json:\"rfq_creator_id\"`\n\tMarketTicker string `json:\"market_ticker\"`\n\tContracts int `json:\"contracts\"`\n\tContractsFP string `json:\"contracts_fp\"`\n\tYesBid int `json:\"yes_bid\"`\n\tNoBid int `json:\"no_bid\"`\n\tYesBidDollars string `json:\"yes_bid_dollars\"`\n\tNoBidDollars string `json:\"no_bid_dollars\"`\n\tCreatedTs string `json:\"created_ts\"`\n\tUpdatedTs string `json:\"updated_ts\"`\n\tStatus string `json:\"status\"`\n\tAcceptedSide string `json:\"accepted_side,omitempty\"`\n\tAcceptedTs string `json:\"accepted_ts,omitempty\"`\n\tConfirmedTs string `json:\"confirmed_ts,omitempty\"`\n\tExecutedTs string `json:\"executed_ts,omitempty\"`\n\tCancelledTs string `json:\"cancelled_ts,omitempty\"`\n\tRestRemainder bool `json:\"rest_remainder\"`\n\tCancellationReason string `json:\"cancellation_reason,omitempty\"`\n\tCreatorUserID string `json:\"creator_user_id\"`\n}\n\n// QuotesResponse is the API response for quotes\ntype QuotesResponse struct {\n\tQuotes []Quote `json:\"quotes\"`\n\tCursor string `json:\"cursor\"`\n}\n\n// QuoteResponse is the API response for a single quote\ntype QuoteResponse struct {\n\tQuote Quote `json:\"quote\"`\n}\n\n// CreateQuoteRequest is the request to create a quote\ntype CreateQuoteRequest struct {\n\tRFQID string `json:\"rfq_id\"`\n\tYesBid int `json:\"yes_bid\"`\n\tNoBid int `json:\"no_bid,omitempty\"`\n}\n\n// CommunicationsID represents the communications ID\ntype CommunicationsID struct {\n\tID string `json:\"id\"`\n}\n\n// CommunicationsIDResponse is the API response for communications ID\ntype CommunicationsIDResponse struct {\n\tCommunicationsID string `json:\"communications_id\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3121,"content_sha256":"60ec2b292e14d162a2526c94e417e891258443d2dc70c6d4ec2b2bbfe5fcaeae"},{"filename":"pkg/models/search.go","content":"package models\n\n// SportsFilter represents a sports filtering option\ntype SportsFilter struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tSport string `json:\"sport\"`\n\tLeague string `json:\"league\"`\n\tCategory string `json:\"category\"`\n}\n\n// SportsFiltersResponse is the API response for sports filters\ntype SportsFiltersResponse struct {\n\tFilters []SportsFilter `json:\"filters\"`\n}\n\n// TagMapping represents a category to tags mapping\ntype TagMapping struct {\n\tCategory string `json:\"category\"`\n\tTags []string `json:\"tags\"`\n}\n\n// TagsResponse is the API response for tags\ntype TagsResponse struct {\n\tMappings []TagMapping `json:\"tag_mappings\"`\n}\n\n// StructuredTarget represents a structured target\ntype StructuredTarget struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\n// StructuredTargetResponse is the API response for a single target\ntype StructuredTargetResponse struct {\n\tTarget StructuredTarget `json:\"structured_target\"`\n}\n\n// StructuredTargetsResponse is the API response for multiple targets\ntype StructuredTargetsResponse struct {\n\tTargets []StructuredTarget `json:\"structured_targets\"`\n\tCursor string `json:\"cursor,omitempty\"`\n}\n\n// Incentive represents a rewards program\ntype Incentive struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tValue float64 `json:\"value\"`\n\tStatus string `json:\"status\"`\n}\n\n// IncentivesResponse is the API response for incentives\ntype IncentivesResponse struct {\n\tIncentives []Incentive `json:\"incentives\"`\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1837,"content_sha256":"b47e60ff5b5739d99b871c436db9aadc06f43a958910145e18e990cb72953fd6"},{"filename":"README.md","content":"# kalshi-cli\n\nA command-line interface for the [Kalshi](https://kalshi.com) prediction market exchange. Trade event contracts, monitor positions, stream real-time market data, and view ASCII candlestick charts from your terminal.\n\nAll commands support `--json` output for machine parsing, `--plain` for piping, and `--yes` to skip confirmations. Defaults to the demo API so you never accidentally trade real money.\n\n## Table of Contents\n\n- [Installation](#installation)\n- [Quick Start](#quick-start)\n- [Authentication](#authentication)\n- [Global Flags](#global-flags)\n- [Commands](#commands)\n - [auth](#auth)\n - [markets](#markets)\n - [events](#events)\n - [orders](#orders)\n - [portfolio](#portfolio)\n - [order-groups](#order-groups)\n - [rfq](#rfq)\n - [quotes](#quotes)\n - [exchange](#exchange)\n - [watch](#watch)\n - [config](#config)\n - [version](#version)\n - [completion](#completion)\n- [Configuration](#configuration)\n- [Bot Integration](#bot-integration)\n- [Architecture](#architecture)\n- [License](#license)\n\n## Installation\n\n### Homebrew (macOS / Linux)\n\n```bash\nbrew install 6missedcalls/tap/kalshi-cli\n```\n\n### Go Install\n\nRequires Go 1.25+:\n\n```bash\ngo install github.com/6missedcalls/kalshi-cli/cmd/kalshi-cli@latest\n```\n\n### Build from Source\n\n```bash\ngit clone https://github.com/6missedcalls/kalshi-cli.git\ncd kalshi-cli\ngo build -o kalshi-cli ./cmd/kalshi-cli\n```\n\n## Quick Start\n\n```bash\n# 1. Authenticate\nkalshi-cli auth login\n\n# 2. Check exchange status\nkalshi-cli exchange status\n\n# 3. Browse markets\nkalshi-cli markets list --status open --limit 20\n\n# 4. View your balance\nkalshi-cli portfolio balance\n\n# 5. View an orderbook\nkalshi-cli markets orderbook KXBTC-26FEB12-B97000\n\n# 6. View candlestick chart for an event\nkalshi-cli events candlesticks KXINXU-26FEB11H1600 --series KXINXU --period 1h \\\n --start 2026-02-10T00:00:00Z --end 2026-02-11T23:00:00Z\n\n# 7. Place an order (demo)\nkalshi-cli orders create --market KXBTC-26FEB12-B97000 --side yes --qty 10 --price 50\n\n# 8. Stream live prices\nkalshi-cli watch ticker KXBTC-26FEB12-B97000\n\n# 9. When ready for production (real money)\nkalshi-cli --prod orders create --market KXBTC-26FEB12-B97000 --side yes --qty 10 --price 50\n```\n\n## Authentication\n\n### Interactive Login\n\n```bash\nkalshi-cli auth login\n```\n\nFollow the prompts to:\n1. Copy the displayed public key\n2. Add it to your Kalshi account at [kalshi.com/account/api-keys](https://kalshi.com/account/api-keys)\n3. Enter the API Key ID when prompted\n\n### Non-Interactive Login (Bots / CI)\n\n```bash\n# Via flags\nkalshi-cli auth login --api-key-id YOUR_KEY_ID --private-key-file /path/to/key.pem\n\n# Via PEM content\nkalshi-cli auth login --api-key-id YOUR_KEY_ID --private-key \"$(cat /path/to/key.pem)\"\n\n# Via environment variables\nexport KALSHI_API_KEY_ID=your-key-id\nexport KALSHI_PRIVATE_KEY=\"$(cat /path/to/key.pem)\"\nkalshi-cli auth login\n```\n\n### Config File Credentials\n\nAdd to `~/.kalshi/config.yaml`:\n\n```yaml\napi_key_id: your-key-id\nprivate_key_path: /path/to/key.pem\n```\n\nCredentials are resolved in order: config file, environment variables, OS keyring.\n\n### Credential Storage\n\n| OS | Backend |\n|----|---------|\n| macOS | Keychain |\n| Linux | Secret Service (GNOME Keyring) |\n| Windows | Credential Manager |\n\n## Global Flags\n\nEvery command accepts these flags:\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--json` | | `false` | Output as JSON (for scripts and automation) |\n| `--plain` | | `false` | Plain text output (for piping) |\n| `--yes` | `-y` | `false` | Skip all confirmation prompts |\n| `--prod` | | `false` | Use production API (default: demo) |\n| `--verbose` | `-v` | `false` | Verbose output for debugging |\n| `--config` | | `~/.kalshi/config.yaml` | Path to config file |\n\n## Commands\n\n---\n\n### auth\n\nManage authentication credentials and API keys.\n\n#### `auth login`\n\nAuthenticate with Kalshi using API credentials.\n\n```\nkalshi-cli auth login [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--api-key-id` | No | | API Key ID (or set `KALSHI_API_KEY_ID` env var) |\n| `--private-key` | No | | Private key PEM content (or set `KALSHI_PRIVATE_KEY` env var) |\n| `--private-key-file` | No | | Path to private key PEM file |\n\nIf no flags are provided, runs in interactive mode.\n\n#### `auth logout`\n\nRemove stored API credentials from the system keyring.\n\n```\nkalshi-cli auth logout\n```\n\nNo additional flags.\n\n#### `auth status`\n\nDisplay the current authentication status and environment.\n\n```\nkalshi-cli auth status\n```\n\nNo additional flags. Use `--json` to get machine-readable output:\n\n```json\n{\n \"logged_in\": true,\n \"api_key_id\": \"abc123...\",\n \"environment\": \"demo\",\n \"authenticated\": true,\n \"exchange_active\": true,\n \"trading_active\": true\n}\n```\n\n#### `auth keys`\n\nManage API keys for your Kalshi account.\n\n#### `auth keys list`\n\nList all API keys associated with your account.\n\n```\nkalshi-cli auth keys list\n```\n\nNo additional flags.\n\n#### `auth keys create`\n\nCreate a new API key.\n\n```\nkalshi-cli auth keys create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--name` | No | | Name for the new API key |\n\n#### `auth keys delete`\n\nDelete an API key by its ID.\n\n```\nkalshi-cli auth keys delete \u003cid>\n```\n\nPositional argument: the API key ID to delete.\n\n---\n\n### markets\n\nCommands for listing, viewing, and analyzing prediction markets.\n\n#### `markets list`\n\nList markets with optional filtering.\n\n```\nkalshi-cli markets list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status: `open`, `closed`, `settled` |\n| `--series` | No | | Filter by series ticker |\n| `--limit` | No | `50` | Maximum number of markets to return |\n\n```bash\nkalshi-cli markets list --status open --limit 20\nkalshi-cli markets list --series KXBTC --json\n```\n\n#### `markets get`\n\nGet detailed information about a specific market.\n\n```\nkalshi-cli markets get \u003cmarket-ticker>\n```\n\nPositional argument: the market ticker.\n\n```bash\nkalshi-cli markets get KXBTC-26FEB12-B97000\n```\n\n#### `markets orderbook`\n\nGet the orderbook for a market with visual bid/ask display.\n\n```\nkalshi-cli markets orderbook \u003cmarket-ticker>\n```\n\nPositional argument: the market ticker.\n\n```bash\nkalshi-cli markets orderbook KXBTC-26FEB12-B97000\nkalshi-cli markets orderbook KXBTC-26FEB12-B97000 --json\n```\n\n#### `markets trades`\n\nGet recent trades for a market.\n\n```\nkalshi-cli markets trades \u003cmarket-ticker> [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--limit` | No | `100` | Maximum number of trades to return |\n\n```bash\nkalshi-cli markets trades KXBTC-26FEB12-B97000 --limit 20\n```\n\n#### `markets candlesticks`\n\nGet candlestick (OHLCV) data for a market. Displays an ASCII candlestick chart above a data table.\n\n```\nkalshi-cli markets candlesticks \u003cmarket-ticker> [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--series` | **Yes** | | Series ticker (e.g., `KXBTC`) |\n| `--period` | No | `1h` | Candlestick period: `1m`, `1h`, `1d` |\n\n```bash\nkalshi-cli markets candlesticks KXBTC-26FEB12-B97000 --series KXBTC\nkalshi-cli markets candlesticks KXBTC-26FEB12-B97000 --series KXBTC --period 1d\n```\n\n#### `markets series list`\n\nList market series with optional category filtering.\n\n```\nkalshi-cli markets series list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--category` | No | | Filter by category (e.g., `Economics`, `Crypto`, `Politics`) |\n| `--limit` | No | `50` | Maximum number of series to return |\n\n```bash\nkalshi-cli markets series list --category Economics\n```\n\n#### `markets series get`\n\nGet details for a specific series.\n\n```\nkalshi-cli markets series get \u003cseries-ticker>\n```\n\nPositional argument: the series ticker.\n\n```bash\nkalshi-cli markets series get KXBTC\n```\n\n---\n\n### events\n\nCommands for listing, viewing, and managing events. An event groups related markets (e.g., \"Bitcoin price range on Feb 12\" has multiple strike-bracket markets under it).\n\n#### `events list`\n\nList events with optional filtering.\n\n```\nkalshi-cli events list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status: `active`, `closed`, `settled` |\n| `--limit` | No | `50` | Maximum number of events to return |\n| `--cursor` | No | | Pagination cursor from a previous response |\n\n```bash\nkalshi-cli events list --limit 20\n```\n\n#### `events get`\n\nGet detailed information about a specific event.\n\n```\nkalshi-cli events get \u003cevent-ticker>\n```\n\nPositional argument: the event ticker.\n\n```bash\nkalshi-cli events get KXBTC-26FEB12\n```\n\n#### `events candlesticks`\n\nGet candlestick (OHLCV) data for an event across all its markets. Displays an ASCII candlestick chart above a data table.\n\n```\nkalshi-cli events candlesticks \u003cevent-ticker> [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--series` | No | Auto-resolved from event | Series ticker |\n| `--period` | No | `1h` | Candlestick period: `1m`, `1h`, `1d` |\n| `--start` | No | | Start time in RFC3339 format |\n| `--end` | No | | End time in RFC3339 format |\n\n```bash\nkalshi-cli events candlesticks KXINXU-26FEB11H1600 \\\n --start 2026-02-10T00:00:00Z --end 2026-02-11T23:00:00Z\n\nkalshi-cli events candlesticks KXINXU-26FEB11H1600 --period 1d \\\n --series KXINXU --start 2026-02-01T00:00:00Z --end 2026-02-11T00:00:00Z\n```\n\nThe chart output looks like:\n\n```\n Event Candlesticks Last: $0.03 -$0.28 (-90.3%)\n\n $0.99 │ │\n │ ┃\n │ ┃\n │ │ ┃\n $0.73 │ │ ┃ ┃\n │ ┃ ┃ ┃\n │ ┃ │ ┃\n │ ┃ │ ┃ ┃\n $0.47 │ ┃ ┃ ┃ ┃ ─ ─ ─ ─ ┃ │\n │ ┃ ┃ ┃ ─ ┃ ┃ ┃\n │─ ┃ ┃ ┃ ┃ ┃\n ││ ┃ │ ┃ │ │ ┃ ┃ ┃\n $0.20 │ ┃ ┃ │ ┃ ─ ┃ ┃\n │ │ ┃ ┃ ┃ ┃ ─ ─ ─ │ ┃\n │ ┃ ┃ ┃ │ ┃\n $0.00 │ │ │ ─ ─\n └──────────────────────────────────────────────\n 02/11 11:00 02/11 15:00 02/11 21:00\n Vol ▁ ▂ ▁ ▁ ▁ █ ▂ ▂ ▄ ▁ ▂ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▆ ▇ ▅\n```\n\n- Green `┃` = bullish candle (close >= open)\n- Red `┃` = bearish candle (close \u003c open)\n- Gray `│` = wicks (high/low beyond body)\n- `─` = doji (open == close at same row)\n- Bottom row: volume sparkline colored per candle direction\n\n#### `events multivariate list`\n\nList multivariate events.\n\n```\nkalshi-cli events multivariate list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status |\n| `--limit` | No | `50` | Maximum number of events to return |\n| `--cursor` | No | | Pagination cursor |\n\n#### `events multivariate get`\n\nGet details for a multivariate event.\n\n```\nkalshi-cli events multivariate get \u003cticker>\n```\n\nPositional argument: the multivariate event ticker.\n\n---\n\n### orders\n\nManage trading orders on the Kalshi exchange.\n\n#### `orders list`\n\nList orders with optional filters.\n\n```\nkalshi-cli orders list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status: `resting`, `canceled`, `executed`, `pending` |\n| `--market` | No | | Filter by market ticker |\n\n```bash\nkalshi-cli orders list --status resting\nkalshi-cli orders list --market KXBTC-26FEB12-B97000 --json\n```\n\n#### `orders get`\n\nGet details for a specific order.\n\n```\nkalshi-cli orders get \u003corder-id>\n```\n\nPositional argument: the order ID.\n\n#### `orders create`\n\nCreate a new order. Shows a preview before submission (skip with `--yes`).\n\n```\nkalshi-cli orders create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--market` | **Yes** | | Market ticker |\n| `--side` | **Yes** | | `yes` or `no` |\n| `--qty` | **Yes** | | Number of contracts |\n| `--price` | **Yes** (limit) | | Price in cents (1-99) |\n| `--action` | No | `buy` | `buy` or `sell` |\n| `--type` | No | `limit` | `limit` or `market` |\n\n```bash\nkalshi-cli orders create --market KXBTC-26FEB12-B97000 --side yes --qty 10 --price 50\nkalshi-cli orders create --market KXBTC-26FEB12-B97000 --side no --qty 5 --price 30 --action sell\nkalshi-cli orders create --market KXBTC-26FEB12-B97000 --side yes --qty 10 --price 50 --yes --json\n```\n\n#### `orders amend`\n\nAmend an existing order's quantity and/or price. At least one of `--qty` or `--price` must be specified.\n\n```\nkalshi-cli orders amend \u003corder-id> [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--qty` | No | | New quantity |\n| `--price` | No | | New price in cents |\n\n#### `orders cancel`\n\nCancel a resting order by its ID.\n\n```\nkalshi-cli orders cancel \u003corder-id>\n```\n\n#### `orders cancel-all`\n\nCancel all resting orders. Optionally filter by market.\n\n```\nkalshi-cli orders cancel-all [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--market` | No | | Only cancel orders for this market ticker |\n\n#### `orders batch-create`\n\nCreate multiple orders from a JSON file.\n\n```\nkalshi-cli orders batch-create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--file` | **Yes** | | Path to JSON file containing orders |\n\nThe JSON file should contain an array of order objects:\n\n```json\n[\n { \"ticker\": \"MARKET1\", \"side\": \"yes\", \"action\": \"buy\", \"type\": \"limit\", \"count\": 10, \"yes_price\": 50 },\n { \"ticker\": \"MARKET2\", \"side\": \"no\", \"action\": \"buy\", \"type\": \"limit\", \"count\": 5, \"no_price\": 30 }\n]\n```\n\n#### `orders queue`\n\nGet the queue position for a resting order.\n\n```\nkalshi-cli orders queue \u003corder-id>\n```\n\n---\n\n### portfolio\n\nView and manage your Kalshi portfolio.\n\n#### `portfolio balance`\n\nDisplay account balance. All values are in cents.\n\n```\nkalshi-cli portfolio balance\n```\n\nNo additional flags.\n\n#### `portfolio positions`\n\nList current market positions.\n\n```\nkalshi-cli portfolio positions [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--market` | No | | Filter by market ticker |\n\n#### `portfolio fills`\n\nList trade fills.\n\n```\nkalshi-cli portfolio fills [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--limit` | No | `100` | Maximum number of fills to return |\n\n#### `portfolio settlements`\n\nList market settlements.\n\n```\nkalshi-cli portfolio settlements [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--limit` | No | `50` | Maximum number of settlements to return |\n\n#### `portfolio subaccounts list`\n\nList all subaccounts.\n\n```\nkalshi-cli portfolio subaccounts list\n```\n\nNo additional flags.\n\n#### `portfolio subaccounts create`\n\nCreate a new subaccount.\n\n```\nkalshi-cli portfolio subaccounts create\n```\n\nNo additional flags.\n\n#### `portfolio subaccounts transfer`\n\nTransfer funds between subaccounts.\n\n```\nkalshi-cli portfolio subaccounts transfer [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--from` | **Yes** | | Source subaccount ID |\n| `--to` | **Yes** | | Destination subaccount ID |\n| `--amount` | **Yes** | | Amount to transfer in cents |\n\n---\n\n### order-groups\n\nOrder groups cap total fills across multiple orders. Alias: `og`.\n\n#### `order-groups list`\n\nList all order groups.\n\n```\nkalshi-cli order-groups list [flags]\nkalshi-cli og list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status |\n\n#### `order-groups get`\n\nGet details for an order group.\n\n```\nkalshi-cli order-groups get \u003cgroup-id>\n```\n\n#### `order-groups create`\n\nCreate a new order group with a contract limit.\n\n```\nkalshi-cli order-groups create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--limit` | **Yes** | | Maximum contracts to fill across all orders in the group |\n\n#### `order-groups delete`\n\nDelete an order group. All orders in the group will be canceled.\n\n```\nkalshi-cli order-groups delete \u003cgroup-id>\n```\n\n#### `order-groups reset`\n\nReset an order group's filled count to zero.\n\n```\nkalshi-cli order-groups reset \u003cgroup-id>\n```\n\n#### `order-groups trigger`\n\nTrigger an order group to execute its orders.\n\n```\nkalshi-cli order-groups trigger \u003cgroup-id>\n```\n\n#### `order-groups update-limit`\n\nUpdate the contract limit for an order group. If the new limit is lower than the current filled count, the group is triggered.\n\n```\nkalshi-cli order-groups update-limit \u003cgroup-id> [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--limit` | **Yes** | | New maximum contracts to fill |\n\n---\n\n### rfq\n\nRequest for Quotes for block trading.\n\n#### `rfq list`\n\nList all RFQs.\n\n```\nkalshi-cli rfq list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--status` | No | | Filter by status (e.g., `open`, `closed`) |\n\n#### `rfq get`\n\nGet details for a specific RFQ.\n\n```\nkalshi-cli rfq get \u003crfq-id>\n```\n\n#### `rfq create`\n\nCreate a new RFQ.\n\n```\nkalshi-cli rfq create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--market` | **Yes** | | Market ticker |\n| `--qty` | **Yes** | | Quantity |\n\n```bash\nkalshi-cli rfq create --market KXBTC-26FEB12-B97000 --qty 1000\n```\n\n#### `rfq delete`\n\nDelete an RFQ.\n\n```\nkalshi-cli rfq delete \u003crfq-id>\n```\n\n---\n\n### quotes\n\nManage quotes on RFQs.\n\n#### `quotes list`\n\nList all quotes, optionally filtered by RFQ.\n\n```\nkalshi-cli quotes list [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--rfq-id` | No | | Filter by RFQ ID |\n\n#### `quotes create`\n\nCreate a quote on an existing RFQ.\n\n```\nkalshi-cli quotes create [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--rfq` | **Yes** | | RFQ ID |\n| `--price` | **Yes** | | Price in cents |\n\n```bash\nkalshi-cli quotes create --rfq rfq_abc123 --price 65\n```\n\n#### `quotes accept`\n\nAccept a quote offered on your RFQ.\n\n```\nkalshi-cli quotes accept \u003cquote-id>\n```\n\n#### `quotes confirm`\n\nConfirm an accepted quote.\n\n```\nkalshi-cli quotes confirm \u003cquote-id>\n```\n\n---\n\n### exchange\n\nGet exchange status, schedule, and announcements.\n\n#### `exchange status`\n\nGet current exchange status including trading activity and environment.\n\n```\nkalshi-cli exchange status\n```\n\nNo additional flags.\n\n#### `exchange schedule`\n\nGet the exchange trading schedule.\n\n```\nkalshi-cli exchange schedule\n```\n\nNo additional flags.\n\n#### `exchange announcements`\n\nGet the latest exchange announcements.\n\n```\nkalshi-cli exchange announcements\n```\n\nNo additional flags.\n\n---\n\n### watch\n\nStream real-time data via WebSocket. All watch commands require authentication. Press `Ctrl+C` to stop. Use `--json` for newline-delimited JSON output.\n\nFeatures:\n- Automatic reconnection with exponential backoff (1s-60s)\n- Ping/pong keepalive (10-second intervals)\n- Subscription persistence across reconnects\n\n#### `watch ticker`\n\nStream live price updates for a market.\n\n```\nkalshi-cli watch ticker \u003cmarket-ticker>\n```\n\nPositional argument: the market ticker.\n\n```bash\nkalshi-cli watch ticker KXBTC-26FEB12-B97000\nkalshi-cli watch ticker KXBTC-26FEB12-B97000 --json\n```\n\n#### `watch orderbook`\n\nStream orderbook delta updates for a market.\n\n```\nkalshi-cli watch orderbook \u003cmarket-ticker>\n```\n\nPositional argument: the market ticker.\n\n#### `watch trades`\n\nStream public trades. Optionally filter to a single market.\n\n```\nkalshi-cli watch trades [flags]\n```\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--market` | No | | Filter trades by market ticker |\n\n```bash\nkalshi-cli watch trades\nkalshi-cli watch trades --market KXBTC-26FEB12-B97000 --json\n```\n\n#### `watch orders`\n\nStream your order status changes.\n\n```\nkalshi-cli watch orders\n```\n\nNo additional flags.\n\n#### `watch fills`\n\nStream your fill notifications.\n\n```\nkalshi-cli watch fills\n```\n\nNo additional flags.\n\n#### `watch positions`\n\nStream your position changes.\n\n```\nkalshi-cli watch positions\n```\n\nNo additional flags.\n\n---\n\n### config\n\nManage configuration settings stored in `~/.kalshi/config.yaml`.\n\n#### `config show`\n\nDisplay all current configuration settings.\n\n```\nkalshi-cli config show\n```\n\n#### `config get`\n\nGet the value of a specific configuration key.\n\n```\nkalshi-cli config get \u003ckey>\n```\n\nAvailable keys:\n\n| Key | Default | Description |\n|-----|---------|-------------|\n| `output.format` | `table` | Output format: `table`, `json`, `plain` |\n| `output.color` | `true` | Enable colored output |\n| `defaults.limit` | `50` | Default result limit for list commands |\n\n#### `config set`\n\nSet a configuration value.\n\n```\nkalshi-cli config set \u003ckey> \u003cvalue>\n```\n\n```bash\nkalshi-cli config set output.format json\nkalshi-cli config set defaults.limit 100\n```\n\n---\n\n### version\n\nPrint version information.\n\n```\nkalshi-cli version\n```\n\n---\n\n### completion\n\nGenerate shell autocompletion scripts.\n\n```\nkalshi-cli completion bash\nkalshi-cli completion zsh\nkalshi-cli completion fish\nkalshi-cli completion powershell\n```\n\n## Configuration\n\nConfiguration file: `~/.kalshi/config.yaml` (created on first run).\n\n```yaml\napi:\n production: false\n timeout: 30s\napi_key_id: \"\"\nprivate_key_path: \"\"\noutput:\n format: table\n color: true\ndefaults:\n limit: 50\n```\n\n### Environment Variables\n\nAll config values can be overridden:\n\n| Variable | Description |\n|----------|-------------|\n| `KALSHI_API_PRODUCTION` | `true` for production |\n| `KALSHI_API_TIMEOUT` | HTTP request timeout (e.g., `60s`) |\n| `KALSHI_OUTPUT_FORMAT` | Default output format |\n| `KALSHI_API_KEY_ID` | API Key ID |\n| `KALSHI_PRIVATE_KEY` | Private key PEM content |\n| `KALSHI_PRIVATE_KEY_FILE` | Path to private key PEM file |\n\n### Demo vs Production\n\n| | Demo | Production |\n|--|------|-----------|\n| **Flag** | (default) | `--prod` |\n| **API** | `demo-api.kalshi.co` | `api.elections.kalshi.com` |\n| **WebSocket** | `wss://demo-api.kalshi.co/trade-api/ws/v2` | `wss://api.elections.kalshi.com/trade-api/ws/v2` |\n\n## Bot Integration\n\n### Automation Flags\n\n```bash\nkalshi-cli --json --yes [command] [subcommand] [flags]\n```\n\n| Flag | Purpose |\n|------|---------|\n| `--json` | Machine-parseable structured output |\n| `--yes` | Skip all interactive confirmations |\n| `--plain` | Unformatted text for piping |\n| `--prod` | Target production |\n\n### Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | Success |\n| 1 | General error |\n| 2 | Authentication error |\n| 3 | Validation error |\n| 4 | API error |\n| 5 | Network error |\n\n### JSON Output Schemas\n\n**Order:**\n```json\n{\n \"order_id\": \"string\",\n \"ticker\": \"string\",\n \"side\": \"yes|no\",\n \"action\": \"buy|sell\",\n \"type\": \"limit|market\",\n \"status\": \"resting|executed|canceled|pending\",\n \"yes_price\": 50,\n \"no_price\": 50,\n \"initial_quantity\": 10,\n \"remaining_quantity\": 5,\n \"created_time\": \"2024-01-01T00:00:00Z\"\n}\n```\n\n**Position:**\n```json\n{\n \"ticker\": \"string\",\n \"position\": 10,\n \"market_exposure\": 500,\n \"realized_pnl\": 100,\n \"total_cost\": 400\n}\n```\n\n**Balance:**\n```json\n{\n \"available_balance\": 10000,\n \"portfolio_value\": 5000,\n \"total_balance\": 15000\n}\n```\n\n**Candlestick:**\n```json\n{\n \"open\": 50,\n \"high\": 60,\n \"low\": 40,\n \"close\": 55,\n \"volume\": 100,\n \"open_interest\": 200,\n \"period_end\": \"2026-02-11T16:00:00Z\"\n}\n```\n\nAll monetary values are in **cents**.\n\n### Example: Market Making Bot\n\n```bash\n#!/bin/bash\nMARKET=\"KXBTC-26FEB12-B97000\"\nBOOK=$(kalshi-cli markets orderbook $MARKET --json)\nBEST_BID=$(echo $BOOK | jq '.yes_bids[0].price // 0')\nBEST_ASK=$(echo $BOOK | jq '.yes_asks[0].price // 100')\n\nkalshi-cli orders create --market $MARKET --side yes --action buy --qty 10 --price $((BEST_BID + 1)) --yes --json\nkalshi-cli orders create --market $MARKET --side yes --action sell --qty 10 --price $((BEST_ASK - 1)) --yes --json\n```\n\n### Example: Position Monitor\n\n```bash\n#!/bin/bash\nkalshi-cli portfolio positions --json | jq '.[] | {ticker, position, pnl}'\nkalshi-cli portfolio balance --json | jq '{available: .available_balance, total: .total_balance}'\n```\n\n### Example: Real-Time Price Feed\n\n```bash\n#!/bin/bash\nkalshi-cli watch ticker KXBTC-26FEB12-B97000 --json | while read line; do\n PRICE=$(echo $line | jq '.yes_price')\n echo \"Current price: $PRICE\"\ndone\n```\n\n## Architecture\n\n```\nkalshi-cli/\n├── cmd/kalshi-cli/ # Entry point\n│ └── main.go\n├── internal/\n│ ├── api/ # HTTP client, RSA-PSS auth signing, all API methods\n│ ├── cmd/ # Cobra command definitions\n│ ├── config/ # Viper config + keyring credential store\n│ ├── ui/ # Table formatting, ASCII candlestick charts, output routing\n│ └── websocket/ # WebSocket client, channel subscriptions, auto-reconnect\n├── pkg/\n│ └── models/ # Shared request/response types\n├── .goreleaser.yaml # Cross-platform release builds\n├── go.mod\n└── go.sum\n```\n\n**Key design decisions:**\n- **Cobra + Viper** for CLI framework and configuration\n- **Resty** HTTP client with automatic retry and rate-limit handling\n- **nhooyr.io/websocket** for WebSocket streaming with auto-reconnect\n- **OS keyring** for credential storage (never plaintext)\n- **RSA-PSS signatures** (`timestamp_ms + METHOD + path`) for API authentication\n- **Demo-first** - production requires explicit `--prod` flag\n- **lipgloss** for terminal styling (green/red price coloring, chart rendering)\n\n## License\n\nMIT License - see [LICENSE](LICENSE) for details.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":26563,"content_sha256":"78d658bd030d3bd6764e84d24ec4b46b4cec32f7cc53a741c0a05961065a0f4d"},{"filename":"references/auth.md","content":"# Auth Commands Reference\n\n## `kalshi-cli auth`\n\nManage authentication credentials and API keys for the Kalshi API.\n\n## `kalshi-cli auth login`\n\nAuthenticate with Kalshi using API credentials provisioned from the Kalshi dashboard.\n\n**Credential resolution order**: flags > environment variables > interactive prompt.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--api-key-id` | string | \"\" | API Key ID (or env `KALSHI_API_KEY_ID`) |\n| `--private-key` | string | \"\" | Private key PEM content (or env `KALSHI_PRIVATE_KEY`) |\n| `--private-key-file` | string | \"\" | Path to private key PEM file |\n\n**Environment variables**: `KALSHI_API_KEY_ID`, `KALSHI_PRIVATE_KEY`\n\n```bash\n# Interactive login\nkalshi-cli auth login\n\n# Non-interactive with file\nkalshi-cli auth login --api-key-id \u003cid> --private-key-file /path/to/key.pem\n\n# Non-interactive with PEM content\nkalshi-cli auth login --api-key-id \u003cid> --private-key \"$(cat key.pem)\"\n\n# Via environment variables\nexport KALSHI_API_KEY_ID=your-key-id\nexport KALSHI_PRIVATE_KEY=\"$(cat key.pem)\"\nkalshi-cli auth login\n```\n\nCredentials are stored in the system keyring after successful login.\n\n## `kalshi-cli auth logout`\n\nRemove stored API credentials from the system keyring. Prompts for confirmation (bypass with `--yes`).\n\n```bash\nkalshi-cli auth logout\nkalshi-cli auth logout --yes\n```\n\n## `kalshi-cli auth status`\n\nDisplay current authentication status and environment.\n\n**Output fields** (JSON): `logged_in`, `api_key_id`, `environment`, `authenticated`, `exchange_active`, `trading_active`.\n\n```bash\nkalshi-cli auth status\nkalshi-cli auth status --json\n```\n\n## `kalshi-cli auth keys list`\n\nList all API keys associated with your account.\n\n**Output columns**: ID, Name, Created, Expires, Scopes.\n\n```bash\nkalshi-cli auth keys list\nkalshi-cli auth keys list --json\n```\n\n## `kalshi-cli auth keys create`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--name` | string | Name for the new API key |\n\n```bash\nkalshi-cli auth keys create --name \"trading-bot\"\n```\n\n## `kalshi-cli auth keys delete \u003cid>`\n\nDelete an API key by its ID. Prompts for confirmation (bypass with `--yes`).\n\n```bash\nkalshi-cli auth keys delete abc123\nkalshi-cli auth keys delete abc123 --yes\n```\n\n## Internal functions\n\n- `getAuthenticatedClient()` - Retrieves credentials from keyring and creates authenticated API client\n- `createAuthenticatedClient(creds)` - Creates API client from credentials using `api.NewSignerFromPEM` and `api.NewClient`\n- `resolveLoginCredentials(keyring)` - Resolves credentials: flags > env vars > interactive input\n- `readPrivateKeyInput(reader)` - Reads multi-line PEM from stdin, or treats first line as file path\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2715,"content_sha256":"1e64cd99895c615ef7ea3178391db1b8b626309a20c5df6eaec2a9d8ae8921c7"},{"filename":"references/config.md","content":"# Config Commands Reference\n\n## `kalshi-cli config`\n\nManage CLI configuration. Config stored at `~/.kalshi/config.yaml`.\n\n## Available configuration keys\n\n| Key | Type | Valid Values | Default | Description |\n|-----|------|-------------|---------|-------------|\n| `output.format` | string | table, json, plain | table | Default output format |\n| `output.color` | bool | true, false | true | Enable colored output |\n| `defaults.limit` | int | Any positive integer | 50 | Default limit for list commands |\n\n## `kalshi-cli config show`\n\nDisplay all current configuration settings with keys, values, and descriptions. Also shows config file path.\n\n```bash\nkalshi-cli config show\nkalshi-cli config show --json\n```\n\n## `kalshi-cli config get \u003ckey>`\n\nGet the value of a specific configuration key.\n\n```bash\nkalshi-cli config get output.format\nkalshi-cli config get defaults.limit --json\n```\n\n## `kalshi-cli config set \u003ckey> \u003cvalue>`\n\nSet a configuration value. Validates before saving.\n\n```bash\nkalshi-cli config set output.format json\nkalshi-cli config set output.color false\nkalshi-cli config set defaults.limit 100\n```\n\n## Environment variables\n\nAll config keys can be set via environment variables with the `KALSHI_` prefix:\n- `KALSHI_API_PRODUCTION=true` maps to `api.production`\n- `KALSHI_OUTPUT_FORMAT=json` maps to `output.format`\n\n## API configuration\n\n| Config Key | Default | Description |\n|------------|---------|-------------|\n| `api.production` | false | Use production API |\n| `api.timeout` | 30s | API request timeout |\n\n## API URLs\n\n| Environment | Base URL | WebSocket URL |\n|-------------|----------|---------------|\n| Demo | `https://demo-api.kalshi.co` | `wss://demo-api.kalshi.co/trade-api/ws/v2` |\n| Production | `https://api.elections.kalshi.com` | `wss://api.elections.kalshi.com/trade-api/ws/v2` |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1817,"content_sha256":"efd6135197cf119a46b2c9fbcd97a1ad243b4800f63d6da3719a1adb6ed2b995"},{"filename":"references/events.md","content":"# Events Commands Reference\n\n## `kalshi-cli events`\n\nCommands for listing, viewing, and managing Kalshi events. An event groups related markets (e.g., \"S&P 500 close on Feb 7\" has multiple price-bracket markets under it).\n\n## `kalshi-cli events list`\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--status` | string | \"\" | Filter: active, closed, settled |\n| `--limit` | int | 50 | Max results |\n| `--cursor` | string | \"\" | Pagination cursor |\n\n```bash\nkalshi-cli events list\nkalshi-cli events list --status active --limit 20\nkalshi-cli events list --json\n```\n\n## `kalshi-cli events get \u003cevent-ticker>`\n\nGet detailed information about a specific event by ticker.\n\n```bash\nkalshi-cli events get INXD-25FEB07\n```\n\n## `kalshi-cli events candlesticks \u003cevent-ticker>`\n\nGet candlestick (OHLCV) data for an event. The `--series` flag is optional; if omitted, the series ticker is auto-resolved from the event.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--series` | string | \"\" | Series ticker (auto-resolved from event if omitted) |\n| `--period` | string | 1h | Candlestick period: 1m, 1h, 1d |\n| `--start` | string | \"\" | Start time (RFC3339 format) |\n| `--end` | string | \"\" | End time (RFC3339 format) |\n\n```bash\nkalshi-cli events candlesticks INXD-25FEB07 --start 2025-02-01T00:00:00Z --end 2025-02-07T00:00:00Z\nkalshi-cli events candlesticks INXD-25FEB07 --period 1d --start 2025-01-01T00:00:00Z --end 2025-02-01T00:00:00Z\nkalshi-cli events candlesticks INXD-25FEB07 --series INXD --period 1h --start 2025-02-06T00:00:00Z --end 2025-02-07T00:00:00Z\n```\n\nIf the event has no series ticker and `--series` is omitted, an error is returned asking the user to provide it explicitly.\n\n## `kalshi-cli events multivariate list`\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--status` | string | \"\" | Filter by status |\n| `--limit` | int | 50 | Max results |\n| `--cursor` | string | \"\" | Pagination cursor |\n\n```bash\nkalshi-cli events multivariate list\nkalshi-cli events multivariate list --status active\n```\n\n## `kalshi-cli events multivariate get \u003cticker>`\n\nGet detailed information about a specific multivariate event.\n\n```bash\nkalshi-cli events multivariate get INXD-MV\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2285,"content_sha256":"55544efa1567923dfd9135197716273ab585c35b287c4b35499507531cb92bcb"},{"filename":"references/exchange.md","content":"# Exchange Commands Reference\n\n## `kalshi-cli exchange`\n\nGet exchange status, schedule, and announcements. All subcommands require authentication.\n\n## `kalshi-cli exchange status`\n\nGet current exchange status including trading activity and environment.\n\n**Output fields**: Exchange Active (Yes/No), Trading Active (Yes/No), Environment (Production/Demo).\n\n```bash\nkalshi-cli exchange status\nkalshi-cli exchange status --prod\nkalshi-cli exchange status --json\nkalshi-cli exchange status --plain\n# Plain output: exchange_active=yes, trading_active=yes, environment=demo\n```\n\n## `kalshi-cli exchange schedule`\n\nGet the exchange trading schedule.\n\n**Output**: Standard hours (per-day open/close times grouped by week), Maintenance windows (start/end datetimes).\n\n```bash\nkalshi-cli exchange schedule\nkalshi-cli exchange schedule --json\nkalshi-cli exchange schedule --plain\n# Plain output: week_0_start=..., week_0_monday_0_open=..., maintenance_0_start=...\n```\n\n## `kalshi-cli exchange announcements`\n\nGet the latest exchange announcements.\n\n**Output columns**: Title (truncated 50 chars), Type, Status (color-coded: green=active, yellow=pending, gray=expired), Delivery Time.\n\n```bash\nkalshi-cli exchange announcements\nkalshi-cli exchange announcements --json\nkalshi-cli exchange announcements --plain\n# Plain output: announcement_0_id=..., announcement_0_title=..., announcement_0_status=...\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1394,"content_sha256":"b71932c06b488dbb63899a90327be66f60005037d40eb082a4fcef973af98b75"},{"filename":"references/markets.md","content":"# Markets Commands Reference\n\n## `kalshi-cli markets`\n\nCommands for listing, viewing, and analyzing prediction markets.\n\n## `kalshi-cli markets list`\n\nList markets with optional filtering.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--status` | string | \"\" | Filter: open, closed, settled |\n| `--limit` | int | 50 | Max results |\n| `--series` | string | \"\" | Filter by series ticker |\n\n**Output columns**: Ticker, Title, Status, Yes Bid, Yes Ask, Volume.\n\n```bash\nkalshi-cli markets list\nkalshi-cli markets list --status open --limit 20\nkalshi-cli markets list --series INXD --json\n```\n\n## `kalshi-cli markets get \u003cmarket-ticker>`\n\nGet detailed information about a specific market.\n\n**Output fields**: Ticker, Title, Subtitle, Status, Category, Yes Bid, Yes Ask, No Bid, No Ask, Last Price, Volume, Volume 24h, Open Interest, Open Time, Close Time, Expiration, Result.\n\n```bash\nkalshi-cli markets get INXD-25FEB07-B5523.99\nkalshi-cli markets get INXD-25FEB07-B5523.99 --json\n```\n\n## `kalshi-cli markets orderbook \u003cmarket-ticker>`\n\nVisual orderbook display with YES bids and asks at each price level.\n\n```bash\nkalshi-cli markets orderbook INXD-25FEB07-B5523.99\nkalshi-cli markets orderbook INXD-25FEB07-B5523.99 --json\n```\n\n## `kalshi-cli markets trades \u003cmarket-ticker>`\n\nGet recent trades for a specific market.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--limit` | int | 100 | Max trades to return |\n\n**Output columns**: Time, Price, Quantity, Side.\n\n```bash\nkalshi-cli markets trades INXD-25FEB07-B5523.99\nkalshi-cli markets trades INXD-25FEB07-B5523.99 --limit 20\n```\n\n## `kalshi-cli markets candlesticks \u003cmarket-ticker>`\n\nGet candlestick (OHLCV) data. Renders ASCII candlestick chart followed by data table.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--series` | string | **required** | Series ticker |\n| `--period` | string | 1h | Period: 1m, 1h, 1d |\n\n**Output**: ASCII chart + table with columns: Time, Open, High, Low, Close, Volume.\n\n```bash\nkalshi-cli markets candlesticks INXD-25FEB07-B5523.99 --series INXD\nkalshi-cli markets candlesticks INXD-25FEB07-B5523.99 --series INXD --period 1d\n```\n\n## `kalshi-cli markets series list`\n\nList market series with optional category filtering.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--category` | string | \"\" | Filter by category |\n| `--limit` | int | 50 | Max results |\n\n**Output columns**: Ticker, Title, Category, Frequency.\n\n```bash\nkalshi-cli markets series list\nkalshi-cli markets series list --category economics\n```\n\n## `kalshi-cli markets series get \u003cseries-ticker>`\n\nGet detailed information about a specific series.\n\n**Output fields**: Ticker, Title, Category, Frequency, Tags.\n\n```bash\nkalshi-cli markets series get INXD\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2854,"content_sha256":"ea887015084d7c5dcd4e892e721b10f290970f620af91f5da50c474dda00861a"},{"filename":"references/models.md","content":"# Data Models Reference\n\nAPI response structures and types used across commands. All prices are in cents (int).\n\n## Market\n\n| Field | Type | Description |\n|-------|------|-------------|\n| Ticker | string | Market identifier |\n| EventTicker | string | Parent event |\n| Title | string | Market title |\n| Subtitle | string | Market subtitle |\n| Status | string | open, closed, settled |\n| Category | string | Market category |\n| YesBid, YesAsk | int | Current yes bid/ask in cents |\n| NoBid, NoAsk | int | Current no bid/ask in cents |\n| LastPrice | int | Last trade price in cents |\n| Volume, Volume24H | int | Total and 24h volume |\n| OpenInterest | int | Open interest |\n| Result | string | Settlement result (if settled) |\n| OpenTime, CloseTime, ExpirationTime | time.Time | Market timing |\n\n## Order\n\n**Constants**: Side: yes/no. Type: limit/market. Status: resting/canceled/executed/pending. Action: buy/sell.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| OrderID | string | Unique order ID |\n| Ticker | string | Market ticker |\n| Status | OrderStatus | Current status |\n| Side | OrderSide | yes or no |\n| Action | OrderAction | buy or sell |\n| Type | OrderType | limit or market |\n| YesPrice, NoPrice | int | Price in cents |\n| InitialCount | int | Original quantity |\n| RemainingCount | int | Unfilled quantity |\n| FillCount | int | Filled quantity |\n| CreatedTime | time.Time | Creation timestamp |\n\n## Event\n\n| Field | Type | Description |\n|-------|------|-------------|\n| EventTicker | string | Event identifier |\n| SeriesTicker | string | Parent series |\n| Title | string | Event title |\n| Category | string | Category |\n| MutuallyExclusive | bool | Markets are mutually exclusive |\n| Markets | []string | List of market tickers |\n\n## Position (MarketPosition)\n\n| Field | Type | Description |\n|-------|------|-------------|\n| Ticker | string | Market ticker |\n| Position | int | Net position |\n| TotalTraded | int | Total contracts traded |\n| MarketExposure | int | Current exposure in cents |\n| RealizedPnl | int | Realized P&L in cents |\n| FeesPaid | int | Total fees in cents |\n\n## Balance (BalanceResponse)\n\n| Field | Type | Description |\n|-------|------|-------------|\n| Balance | int | Available balance in cents |\n| PortfolioValue | int | Portfolio value in cents |\n\n## Fill\n\n| Field | Type | Description |\n|-------|------|-------------|\n| TradeID | string | Trade identifier |\n| OrderID | string | Parent order |\n| Ticker | string | Market ticker |\n| Side | string | yes or no |\n| Action | string | buy or sell |\n| YesPrice, NoPrice | int | Execution price in cents |\n| Count | int | Fill quantity |\n| IsTaker | bool | Taker or maker |\n| CreatedTime | time.Time | Fill timestamp |\n\n## OrderGroup\n\n| Field | Type | Description |\n|-------|------|-------------|\n| GroupID | string | Group identifier |\n| Status | string | Group status |\n| Limit | int | Max contracts to fill |\n| FilledCount | int | Current filled count |\n| OrderCount | int | Number of orders in group |\n| OrderIDs | []string | Order identifiers |\n\n## RFQ\n\n| Field | Type | Description |\n|-------|------|-------------|\n| ID | string | RFQ identifier |\n| MarketTicker | string | Market ticker |\n| Status | string | RFQ status |\n| Contracts | int | Requested quantity |\n\n## Quote\n\n| Field | Type | Description |\n|-------|------|-------------|\n| ID | string | Quote identifier |\n| RFQID | string | Parent RFQ |\n| MarketTicker | string | Market ticker |\n| Status | string | Quote status |\n| Contracts | int | Quantity |\n| YesBid, NoBid | int | Bid prices in cents |\n\n## Candlestick\n\n| Field | Type | Description |\n|-------|------|-------------|\n| Open, High, Low, Close | int | OHLC prices in cents |\n| Volume | int | Period volume |\n| Timestamp | time.Time | Candle start time |\n\n## API response wrappers\n\n- Single items: `{\"object\": {...}}`\n- Lists: `{\"objects\": [...], \"cursor\": \"...\"}`\n- Use `cursor` value in `--cursor` flag for pagination\n\n## API error format\n\n```json\n{\"code\": \"not_found\", \"message\": \"Market not found\", \"status_code\": 404}\n```\n\nAutomatic retry on 429 (rate limit) and 5xx errors with exponential backoff (100ms base, 10s max, 5 retries).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4159,"content_sha256":"998f040400c8f0fb5950dc98f6d87ed1549712e3772b2ad8da208fe4a456bce7"},{"filename":"references/order-groups.md","content":"# Order Groups Commands Reference\n\n## `kalshi-cli order-groups` (alias: `og`)\n\nOrder groups allow you to group multiple orders and manage them as a single unit with a shared contract fill limit.\n\n## `kalshi-cli order-groups list`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--status` | string | Filter by status |\n\n**Output columns**: Group ID, Status, Limit, Filled, Order Count.\n\n```bash\nkalshi-cli order-groups list\nkalshi-cli og list --status active --json\n```\n\n## `kalshi-cli order-groups get \u003cgroup-id>`\n\n**Output fields**: Group ID, Status, Limit, Filled Count, Order Count, Created, Last Updated, Order IDs.\n\n```bash\nkalshi-cli order-groups get abc-123\nkalshi-cli og get abc-123 --json\n```\n\n## `kalshi-cli order-groups create`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--limit` | int | **required** - Max contracts to fill across group (must be > 0) |\n\n```bash\nkalshi-cli order-groups create --limit 100\nkalshi-cli og create --limit 50 --json\n```\n\n## `kalshi-cli order-groups delete \u003cgroup-id>`\n\nDelete an order group. All orders in the group will be canceled. Prompts for confirmation.\n\n```bash\nkalshi-cli order-groups delete abc-123\nkalshi-cli og delete abc-123 --yes\n```\n\n## `kalshi-cli order-groups reset \u003cgroup-id>`\n\nReset an order group's filled count to zero, allowing more orders to fill.\n\n```bash\nkalshi-cli order-groups reset abc-123\n```\n\n## `kalshi-cli order-groups trigger \u003cgroup-id>`\n\nTrigger an order group to execute its orders.\n\n```bash\nkalshi-cli order-groups trigger abc-123\n```\n\n## `kalshi-cli order-groups update-limit \u003cgroup-id>`\n\nUpdate the max contract limit. If new limit \u003c current filled count, the group will be triggered.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--limit` | int | **required** - New max contracts (>= 0) |\n\n```bash\nkalshi-cli order-groups update-limit abc-123 --limit 200\nkalshi-cli og update-limit abc-123 --limit 0\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1937,"content_sha256":"9e0856330a35deab92c30e9e30648fae70ad8a400c1badfe6438c95901742e21"},{"filename":"references/orders.md","content":"# Orders Commands Reference\n\n## `kalshi-cli orders`\n\nManage trading orders on the Kalshi exchange.\n\n## `kalshi-cli orders list`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--status` | string | Filter: resting, canceled, executed, pending |\n| `--market` | string | Filter by market ticker |\n\n```bash\nkalshi-cli orders list\nkalshi-cli orders list --status resting\nkalshi-cli orders list --market INXD-25FEB07-B5523.99 --json\n```\n\n## `kalshi-cli orders get \u003corder-id>`\n\nGet detailed information about a specific order.\n\n```bash\nkalshi-cli orders get abc123-def456-ghi789\n```\n\n## `kalshi-cli orders create`\n\nCreate a new limit order. Shows order preview before submission. Requires confirmation unless `--yes` is set.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--market` | string | **required** | Market ticker |\n| `--side` | string | **required** | yes or no |\n| `--qty` | int | **required** | Quantity |\n| `--price` | int | **required** | Price in cents (1-99) |\n| `--action` | string | buy | buy or sell |\n| `--type` | string | limit | limit or market |\n\n**Validation**:\n- Price must be 1-99 cents\n- Side must be \"yes\" or \"no\"\n- Action must be \"buy\" or \"sell\"\n- Type must be \"limit\" or \"market\"\n- Quantity must be positive\n- Shows PRODUCTION warning when using `--prod`\n\n```bash\nkalshi-cli orders create --market INXD-25FEB07-B5523.99 --side yes --qty 10 --price 50\nkalshi-cli orders create --market INXD-25FEB07-B5523.99 --side no --qty 5 --price 30 --action sell\nkalshi-cli orders create --market INXD-25FEB07-B5523.99 --side yes --qty 10 --price 50 --yes\n```\n\n## `kalshi-cli orders cancel \u003corder-id>`\n\nCancel a resting order by ID. Prompts for confirmation.\n\n```bash\nkalshi-cli orders cancel abc123\nkalshi-cli orders cancel abc123 --yes\n```\n\n## `kalshi-cli orders cancel-all`\n\nCancel all resting orders, optionally filtered by market.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--market` | string | Filter by market ticker |\n\n```bash\nkalshi-cli orders cancel-all\nkalshi-cli orders cancel-all --market INXD-25FEB07-B5523.99\nkalshi-cli orders cancel-all --yes\n```\n\n## `kalshi-cli orders amend \u003corder-id>`\n\nAmend an existing order's quantity and/or price. At least one of `--qty` or `--price` must be specified.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--qty` | int | New quantity |\n| `--price` | int | New price in cents (1-99) |\n\n```bash\nkalshi-cli orders amend abc123 --price 55\nkalshi-cli orders amend abc123 --qty 20 --price 60\n```\n\n## `kalshi-cli orders batch-create`\n\nCreate multiple orders from a JSON file. Shows batch preview and requires confirmation.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--file` | string | **required** - Path to JSON file |\n\n**JSON format**:\n```json\n[\n {\n \"ticker\": \"INXD-25FEB07-B5523.99\",\n \"side\": \"yes\",\n \"action\": \"buy\",\n \"type\": \"limit\",\n \"count\": 10,\n \"yes_price\": 50\n }\n]\n```\n\n**Validation per order**: ticker required, side must be yes/no, count must be positive, prices 1-99.\n\n```bash\nkalshi-cli orders batch-create --file orders.json\nkalshi-cli orders batch-create --file orders.json --yes\n```\n\n## `kalshi-cli orders queue \u003corder-id>`\n\nGet the queue position for a resting order.\n\n```bash\nkalshi-cli orders queue abc123\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3315,"content_sha256":"45b2eda523ff54c7f766ad2a5dbcbb8e8f7039173a3a9c7754515ea3254eb32f"},{"filename":"references/portfolio.md","content":"# Portfolio Commands Reference\n\n## `kalshi-cli portfolio`\n\nView and manage your Kalshi portfolio including balance, positions, fills, settlements, and subaccounts.\n\n## `kalshi-cli portfolio balance`\n\nDisplay current account balance including available balance, portfolio value, and total balance. All values in cents.\n\n```bash\nkalshi-cli portfolio balance\nkalshi-cli portfolio balance --json\n```\n\n## `kalshi-cli portfolio positions`\n\nList current market positions with average cost, P&L, and exposure.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--market` | string | Filter by market ticker |\n\n```bash\nkalshi-cli portfolio positions\nkalshi-cli portfolio positions --market INXD-25FEB07-B5523.99\n```\n\n## `kalshi-cli portfolio fills`\n\nList trade fills showing executed orders and details.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--limit` | int | 100 | Max fills to return |\n\n```bash\nkalshi-cli portfolio fills\nkalshi-cli portfolio fills --limit 20\n```\n\n## `kalshi-cli portfolio settlements`\n\nList market settlements showing resolved positions and outcomes.\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--limit` | int | 50 | Max settlements to return |\n\n```bash\nkalshi-cli portfolio settlements\nkalshi-cli portfolio settlements --limit 10\n```\n\n## `kalshi-cli portfolio subaccounts list`\n\nList all subaccounts associated with your account.\n\n```bash\nkalshi-cli portfolio subaccounts list\n```\n\n## `kalshi-cli portfolio subaccounts create`\n\nCreate a new subaccount.\n\n```bash\nkalshi-cli portfolio subaccounts create\n```\n\n## `kalshi-cli portfolio subaccounts transfer`\n\nTransfer funds between subaccounts. Prompts for confirmation.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--from` | int | **required** - Source subaccount ID |\n| `--to` | int | **required** - Destination subaccount ID |\n| `--amount` | int | **required** - Amount in cents (must be positive) |\n\n```bash\nkalshi-cli portfolio subaccounts transfer --from 1 --to 2 --amount 10000\nkalshi-cli portfolio subaccounts transfer --from 1 --to 2 --amount 10000 --yes\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2142,"content_sha256":"ed027a596eabd50796ba0c4e56416cfd014232ca4be2654ef8702b2877ded97b"},{"filename":"references/rfq-quotes.md","content":"# RFQ and Quotes Commands Reference\n\nTwo top-level command groups for block trading: `rfq` and `quotes`.\n\n## `kalshi-cli rfq list`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--status` | string | Filter: open, closed |\n\n```bash\nkalshi-cli rfq list\nkalshi-cli rfq list --status open\n```\n\n## `kalshi-cli rfq get \u003crfq-id>`\n\n```bash\nkalshi-cli rfq get rfq_abc123\n```\n\n## `kalshi-cli rfq create`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--market` | string | **required** - Market ticker |\n| `--qty` | int | **required** - Quantity (must be > 0) |\n\n```bash\nkalshi-cli rfq create --market INXD-25FEB07 --qty 1000\n```\n\n## `kalshi-cli rfq delete \u003crfq-id>`\n\nPrompts for confirmation before deleting.\n\n```bash\nkalshi-cli rfq delete rfq_abc123\n```\n\n## `kalshi-cli quotes list`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--rfq-id` | string | Filter by RFQ ID |\n\n```bash\nkalshi-cli quotes list\nkalshi-cli quotes list --rfq-id rfq_abc123\n```\n\n## `kalshi-cli quotes create`\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--rfq` | string | **required** - RFQ ID |\n| `--price` | int | **required** - Price in cents (1-99) |\n\n```bash\nkalshi-cli quotes create --rfq rfq_abc123 --price 65\n```\n\n## `kalshi-cli quotes accept \u003cquote-id>`\n\nAccept a quote offered on your RFQ. Prompts for confirmation.\n\n```bash\nkalshi-cli quotes accept quote_xyz789\n```\n\n## `kalshi-cli quotes confirm \u003cquote-id>`\n\nConfirm a quote after acceptance. Prompts for confirmation.\n\n```bash\nkalshi-cli quotes confirm quote_xyz789\n```\n\n## Block trading workflow\n\n1. Create an RFQ: `kalshi-cli rfq create --market TICKER --qty 1000`\n2. Wait for quotes: `kalshi-cli quotes list --rfq-id RFQ_ID`\n3. Accept best quote: `kalshi-cli quotes accept QUOTE_ID`\n4. Confirm the trade: `kalshi-cli quotes confirm QUOTE_ID`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1840,"content_sha256":"a57929e4ed0c2a0d4916af314219ea101e08210d571bc38bac94bc6fb0092964"},{"filename":"references/watch.md","content":"# Watch Commands Reference\n\n## `kalshi-cli watch`\n\nStream real-time data from Kalshi via WebSocket. All watch commands require authentication. Press Ctrl+C to stop.\n\n## `kalshi-cli watch ticker \u003cmarket-ticker>`\n\nLive price updates for a market. Output includes bid/ask prices, volume, and open interest.\n\n```bash\nkalshi-cli watch ticker INXD-25FEB07-B5523.99\nkalshi-cli watch ticker INXD-25FEB07-B5523.99 --json\nkalshi-cli watch ticker INXD-25FEB07-B5523.99 --plain\n```\n\n## `kalshi-cli watch orderbook \u003cmarket-ticker>`\n\nLive orderbook delta updates. Shows best bid/ask, depth, and changes as they occur.\n\n```bash\nkalshi-cli watch orderbook INXD-25FEB07-B5523.99\nkalshi-cli watch orderbook INXD-25FEB07-B5523.99 --json\n```\n\n## `kalshi-cli watch trades`\n\nPublic trades feed across all markets with optional filtering.\n\n| Flag | Type | Description |\n|------|------|-------------|\n| `--market` | string | Filter trades by market ticker |\n\n```bash\nkalshi-cli watch trades\nkalshi-cli watch trades --market INXD-25FEB07-B5523.99\nkalshi-cli watch trades --json\n```\n\n## `kalshi-cli watch orders`\n\nYour order status changes (fills, cancellations, status transitions).\n\n```bash\nkalshi-cli watch orders\nkalshi-cli watch orders --json\n```\n\n## `kalshi-cli watch fills`\n\nYour fill notifications with price, count, and taker/maker status.\n\n```bash\nkalshi-cli watch fills\nkalshi-cli watch fills --json\n```\n\n## `kalshi-cli watch positions`\n\nYour position changes with realized PnL, exposure, and total cost.\n\n```bash\nkalshi-cli watch positions\nkalshi-cli watch positions --json\n```\n\n## Internal WebSocket channels (not exposed as CLI subcommands)\n\nThese handlers exist in code but are not registered as CLI subcommands:\n\n- `market_ticker_v2` - Incremental delta messages with delta_type, yes/no prices\n- `market_lifecycle` - Market status transitions (old_status -> new_status)\n- `order_group_updates` - Order group status changes with total/filled counts\n- `communications` - RFQ/quote messages with type, ticker, quantity, price, side\n\n## WebSocket URLs\n\n- Demo: `wss://demo-api.kalshi.co/trade-api/ws/v2`\n- Production: `wss://api.elections.kalshi.com/trade-api/ws/v2`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2153,"content_sha256":"7716cdb60c194d4f6e2c6dc883228629f2b814846b9b1fc505cb713ff9ae5b41"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"kalshi-cli","type":"text"}]},{"type":"paragraph","content":[{"text":"A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: ","type":"text"},{"text":"kalshi-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to use","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this skill when:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Creating, amending, or canceling trading orders on Kalshi","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Querying market data, events, series, or orderbooks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Managing portfolio positions, fills, settlements, or subaccounts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Managing authentication credentials and API keys","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Working with RFQs (Request for Quotes) and block trading","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Managing order groups for grouped order execution","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Checking exchange status, schedule, or announcements","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Configuring CLI output format and defaults","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Get API credentials from https://kalshi.com/account/api (prod) or https://demo.kalshi.com/account/api (demo)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Login: ","type":"text"},{"text":"kalshi-cli auth login --api-key-id \u003cid> --private-key-file /path/to/key.pem","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"By default all commands use the ","type":"text"},{"text":"demo","type":"text","marks":[{"type":"strong"}]},{"text":" environment. Add ","type":"text"},{"text":"--prod","type":"text","marks":[{"type":"code_inline"}]},{"text":" for production.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Global flags","type":"text"}]},{"type":"paragraph","content":[{"text":"Available on ALL commands:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Short","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--config","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~/.kalshi/config.yaml","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Config file path","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--prod","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use production API","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Output as JSON","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--plain","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Plain text output (for pipes/scripts)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"-y","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Skip confirmation prompts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--verbose","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"-v","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bool","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verbose output","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Command tree","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"kalshi-cli\n├── auth # Manage authentication\n│ ├── login # Authenticate with Kalshi\n│ ├── logout # Clear stored credentials\n│ ├── status # Show auth status\n│ └── keys # Manage API keys\n│ ├── list # List API keys\n│ ├── create # Create new API key\n│ └── delete \u003cid> # Delete an API key\n├── markets # Market data\n│ ├── list # List markets\n│ ├── get \u003cticker> # Get market details\n│ ├── orderbook \u003cticker> # Visual orderbook display\n│ ├── trades \u003cticker> # Recent trades\n│ ├── candlesticks \u003cticker> # OHLCV candlestick data\n│ └── series # Market series\n│ ├── list # List series\n│ └── get \u003cticker> # Get series details\n├── events # Event data\n│ ├── list # List events\n│ ├── get \u003cticker> # Get event details\n│ ├── candlesticks \u003cticker> # Event OHLCV data\n│ └── multivariate # Multivariate events\n│ ├── list # List multivariate events\n│ └── get \u003cticker> # Get multivariate event\n├── orders # Order management\n│ ├── list # List orders\n│ ├── get \u003corder-id> # Get order details\n│ ├── create # Create new order\n│ ├── cancel \u003corder-id> # Cancel an order\n│ ├── cancel-all # Cancel all resting orders\n│ ├── amend \u003corder-id> # Amend order qty/price\n│ ├── batch-create # Create orders from JSON file\n│ └── queue \u003corder-id> # Get queue position\n├── portfolio # Portfolio management\n│ ├── balance # Show account balance\n│ ├── positions # List positions\n│ ├── fills # List trade fills\n│ ├── settlements # List settlements\n│ └── subaccounts # Subaccount management\n│ ├── list # List subaccounts\n│ ├── create # Create subaccount\n│ └── transfer # Transfer between subaccounts\n├── order-groups (alias: og) # Grouped order management\n│ ├── list # List order groups\n│ ├── get \u003cgroup-id> # Get group details\n│ ├── create # Create order group\n│ ├── delete \u003cgroup-id> # Delete order group\n│ ├── reset \u003cgroup-id> # Reset filled count\n│ ├── trigger \u003cgroup-id> # Trigger order group\n│ └── update-limit \u003cgroup-id> # Update contract limit\n├── rfq # Request for Quotes\n│ ├── list # List RFQs\n│ ├── get \u003crfq-id> # Get RFQ details\n│ ├── create # Create new RFQ\n│ └── delete \u003crfq-id> # Delete an RFQ\n├── quotes # Quote management (top-level)\n│ ├── list # List quotes\n│ ├── create # Create quote on RFQ\n│ ├── accept \u003cquote-id> # Accept a quote\n│ └── confirm \u003cquote-id> # Confirm a quote\n├── exchange # Exchange information\n│ ├── status # Exchange status\n│ ├── schedule # Trading schedule\n│ └── announcements # Exchange announcements\n├── watch # Real-time WebSocket streams\n│ ├── ticker \u003cticker> # Live price updates\n│ ├── orderbook \u003cticker> # Orderbook deltas\n│ ├── trades # Public trades feed\n│ ├── orders # Your order updates\n│ ├── fills # Your fill notifications\n│ └── positions # Your position changes\n├── config # CLI configuration\n│ ├── show # Show all settings\n│ ├── get \u003ckey> # Get a config value\n│ └── set \u003ckey> \u003cvalue> # Set a config value\n└── version # Print version info","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick reference: All command-specific flags","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"auth login","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--api-key-id","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API Key ID (or env ","type":"text"},{"text":"KALSHI_API_KEY_ID","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--private-key","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Private key PEM content (or env ","type":"text"},{"text":"KALSHI_PRIVATE_KEY","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--private-key-file","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Path to private key PEM file","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"auth keys create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--name","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Name for the new API key","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"markets list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter: open, closed, settled","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"50","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max results","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--series","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by series ticker","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"markets trades","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max trades to return","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"markets candlesticks","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--series","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Series ticker","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--period","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1h","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Period: 1m, 1h, 1d","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"markets series list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--category","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by category","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"50","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max results","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"events list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter: active, closed, settled","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"50","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max results","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--cursor","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pagination cursor","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"events candlesticks","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--series","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Series ticker (auto-resolved if omitted)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--period","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1h","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Period: 1m, 1h, 1d","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--start","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Start time (RFC3339)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--end","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"End time (RFC3339)","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"events multivariate list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by status","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"50","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max results","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--cursor","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pagination cursor","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"orders list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter: resting, canceled, executed, pending","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by market ticker","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"orders create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Market ticker","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--side","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yes or no","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--qty","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Quantity","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--price","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Price in cents (1-99)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--action","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"buy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"buy or sell","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--type","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"limit","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"limit or market","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"orders cancel-all","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by market ticker","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"orders amend","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--qty","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New quantity (at least one of qty/price required)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--price","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New price in cents (at least one of qty/price required)","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"orders batch-create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--file","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Path to JSON file with order array","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Batch JSON format: ","type":"text"},{"text":"[{\"ticker\":\"...\",\"side\":\"yes\",\"action\":\"buy\",\"type\":\"limit\",\"count\":10,\"yes_price\":50}]","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"portfolio positions","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by market ticker","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"portfolio fills","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max fills to return","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"portfolio settlements","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"50","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max settlements to return","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"portfolio subaccounts transfer","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--from","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Source subaccount ID","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--to","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Destination subaccount ID","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--amount","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Amount in cents","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"order-groups list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by status","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"order-groups create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Max contracts to fill across group","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"order-groups update-limit","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - New max contracts to fill","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"rfq list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter: open, closed","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"rfq create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Market ticker","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--qty","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Quantity (must be > 0)","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"quotes list","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--rfq-id","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter by RFQ ID","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"quotes create","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--rfq","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - RFQ ID","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--price","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"int","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" - Price in cents (1-99)","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"watch trades","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--market","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Filter trades by market ticker","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"config set / config get","type":"text"}]},{"type":"paragraph","content":[{"text":"Valid keys: ","type":"text"},{"text":"output.format","type":"text","marks":[{"type":"code_inline"}]},{"text":" (table/json/plain), ","type":"text"},{"text":"output.color","type":"text","marks":[{"type":"code_inline"}]},{"text":" (true/false), ","type":"text"},{"text":"defaults.limit","type":"text","marks":[{"type":"code_inline"}]},{"text":" (positive int)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common patterns","type":"text"}]},{"type":"paragraph","content":[{"text":"All prices are in cents","type":"text","marks":[{"type":"strong"}]},{"text":" (1-99 for contract prices, larger for balances). Display helpers convert to dollars.","type":"text"}]},{"type":"paragraph","content":[{"text":"Output formats","type":"text","marks":[{"type":"strong"}]},{"text":": Every command supports ","type":"text"},{"text":"--json","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--plain","type":"text","marks":[{"type":"code_inline"}]},{"text":", and table (default). Use ","type":"text"},{"text":"--plain","type":"text","marks":[{"type":"code_inline"}]},{"text":" for scripting.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pagination","type":"text","marks":[{"type":"strong"}]},{"text":": List commands accept ","type":"text"},{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]},{"text":" and some accept ","type":"text"},{"text":"--cursor","type":"text","marks":[{"type":"code_inline"}]},{"text":" for cursor-based pagination.","type":"text"}]},{"type":"paragraph","content":[{"text":"Confirmation prompts","type":"text","marks":[{"type":"strong"}]},{"text":": Destructive actions (cancel, delete, transfer) prompt for confirmation. Use ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" to bypass.","type":"text"}]},{"type":"paragraph","content":[{"text":"Credential resolution","type":"text","marks":[{"type":"strong"}]},{"text":" (auth login): flags > env vars (","type":"text"},{"text":"KALSHI_API_KEY_ID","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"KALSHI_PRIVATE_KEY","type":"text","marks":[{"type":"code_inline"}]},{"text":") > interactive prompt.","type":"text"}]},{"type":"paragraph","content":[{"text":"Environment","type":"text","marks":[{"type":"strong"}]},{"text":": Demo by default. Add ","type":"text"},{"text":"--prod","type":"text","marks":[{"type":"code_inline"}]},{"text":" for production. Config: ","type":"text"},{"text":"~/.kalshi/config.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Detailed references","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Auth commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/auth.md","title":null}}]},{"text":" - Login flows, credential storage, API key management","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Markets commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/markets.md","title":null}}]},{"text":" - Market data, orderbook, trades, candlesticks, series","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Events commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/events.md","title":null}}]},{"text":" - Events, multivariate events, event candlesticks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Orders commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/orders.md","title":null}}]},{"text":" - Order lifecycle, batch creation, amendments","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Portfolio commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/portfolio.md","title":null}}]},{"text":" - Balance, positions, fills, settlements, subaccounts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Order groups commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/order-groups.md","title":null}}]},{"text":" - Grouped order management","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RFQ and quotes commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/rfq-quotes.md","title":null}}]},{"text":" - Block trading workflow","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Exchange commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/exchange.md","title":null}}]},{"text":" - Exchange status, schedule, announcements","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Watch commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/watch.md","title":null}}]},{"text":" - WebSocket real-time streaming","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Config commands","type":"text","marks":[{"type":"link","attrs":{"href":"references/config.md","title":null}}]},{"text":" - CLI configuration management","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Data models","type":"text","marks":[{"type":"link","attrs":{"href":"references/models.md","title":null}}]},{"text":" - API response structures and types","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"kalshi-cli","author":"@skillopedia","source":{"stars":0,"repo_name":"kalshi-cli","origin_url":"https://github.com/6missedcalls/kalshi-cli/blob/HEAD/SKILL.md","repo_owner":"6missedcalls","body_sha256":"1ebf3d4e116d8cb53cca758b86c7a8033594c61dce236b12b8bcc333c6559c7d","cluster_key":"c1950246b1795a6881255286a606fe8dd37bed6e2fb4c5c9a95d8fc08e41bd03","clean_bundle":{"format":"clean-skill-bundle-v1","source":"6missedcalls/kalshi-cli/SKILL.md","attachments":[{"id":"63127527-456d-5845-aa3c-83f959fa46c6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/63127527-456d-5845-aa3c-83f959fa46c6/attachment.md","path":".github/PULL_REQUEST_TEMPLATE.md","size":375,"sha256":"3b1e874f58f3cf1e64c80c3ac0811eb88c3d3b89c60af8faa4488fd521c16659","contentType":"text/markdown; charset=utf-8"},{"id":"cb637f75-8221-5d6b-b561-f2411dce19fd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cb637f75-8221-5d6b-b561-f2411dce19fd/attachment.yml","path":".github/workflows/ci.yml","size":1420,"sha256":"61aaa4f0b63d155892dd554ca8b6ff2886661a41ca529bb4a714561dca00ae1a","contentType":"application/yaml; charset=utf-8"},{"id":"8b377d01-f7e9-5fd3-80da-b6bff979fca5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8b377d01-f7e9-5fd3-80da-b6bff979fca5/attachment.yml","path":".github/workflows/release.yml","size":719,"sha256":"f1dbc411fac782ff5b89877262f655f76a7d5c29e6a513bb9da2bf8562cb3fdf","contentType":"application/yaml; charset=utf-8"},{"id":"f31f7dc3-28fa-59de-9075-96f97800a58c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f31f7dc3-28fa-59de-9075-96f97800a58c/attachment","path":".gitignore","size":548,"sha256":"cca618e7ae15580b93dd0258fdf313d79239b504703064a0f468811217544bd9","contentType":"text/plain; charset=utf-8"},{"id":"76486d72-b32a-52c5-83f7-8394d8f7fc63","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/76486d72-b32a-52c5-83f7-8394d8f7fc63/attachment.yaml","path":".goreleaser.yaml","size":1763,"sha256":"ae8efd5a1094913a38b67e3cf6c2f844bee6b2bf06dd60afea942a360a7e84d5","contentType":"application/yaml; charset=utf-8"},{"id":"15780f42-07be-565d-abde-0bdfd6823d74","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/15780f42-07be-565d-abde-0bdfd6823d74/attachment.md","path":"README.md","size":26563,"sha256":"78d658bd030d3bd6764e84d24ec4b46b4cec32f7cc53a741c0a05961065a0f4d","contentType":"text/markdown; charset=utf-8"},{"id":"f8d04eef-e0f5-5c9d-b6e8-2e46cade1623","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f8d04eef-e0f5-5c9d-b6e8-2e46cade1623/attachment.go","path":"cmd/kalshi-cli/main.go","size":285,"sha256":"83de877c53f16937bb77b31b4ba50e538410839ba277b94a6bc2edc832febd90","contentType":"text/plain; charset=utf-8"},{"id":"fd863a51-849a-569c-9211-59f8d10f5b05","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fd863a51-849a-569c-9211-59f8d10f5b05/attachment.mod","path":"go.mod","size":2537,"sha256":"8e1e3e325cea42334a1e54b173416182e21396a767f1ce4d927bbf144c2f2ef1","contentType":"application/xml-dtd"},{"id":"21a9396b-e6e3-5a96-a4b2-a16eb2f6cf1b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/21a9396b-e6e3-5a96-a4b2-a16eb2f6cf1b/attachment.sum","path":"go.sum","size":12288,"sha256":"7ac000c1aa6ffc4d9b0415879573c76cc227bfdc135e21547f3497a9815d8399","contentType":"text/plain; charset=utf-8"},{"id":"93760b8b-83a5-5f2a-b9d8-c1b0c9dc6938","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/93760b8b-83a5-5f2a-b9d8-c1b0c9dc6938/attachment.go","path":"internal/api/account_test.go","size":14911,"sha256":"8f132703aca245d0c4a3f7cb9316e72a1de5fe3ead9ae1dfeabc32ca34336d97","contentType":"text/plain; charset=utf-8"},{"id":"9ffa44fb-c3a6-53a2-90f8-2e6c956021dd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9ffa44fb-c3a6-53a2-90f8-2e6c956021dd/attachment.go","path":"internal/api/auth.go","size":3702,"sha256":"6de5e77ea7559602e62fb5aa1eca98ff79fe949ec586f110a8541fd2da1b270b","contentType":"text/plain; charset=utf-8"},{"id":"f778be8f-a26a-530a-9eee-3daa61363fd5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f778be8f-a26a-530a-9eee-3daa61363fd5/attachment.go","path":"internal/api/auth_test.go","size":3570,"sha256":"c934fe7fbbdf9ac42275d660e138abb79aa5684ee43c9a4d0d40a980ea85e224","contentType":"text/plain; charset=utf-8"},{"id":"2a3fd682-3be7-5f6a-aacd-ac44d48c6e35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2a3fd682-3be7-5f6a-aacd-ac44d48c6e35/attachment.go","path":"internal/api/candlesticks_v2_test.go","size":6790,"sha256":"c10d74e01c9020383e6e30b27eac47144681e6a27e88725c67e5121168b810e0","contentType":"text/plain; charset=utf-8"},{"id":"282c42ca-9d17-580e-86ad-682150133975","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/282c42ca-9d17-580e-86ad-682150133975/attachment.go","path":"internal/api/client.go","size":15074,"sha256":"aabc4eb39a803cfe37f064ec24d92a64119aca4873f39c87808dc192560505ec","contentType":"text/plain; charset=utf-8"},{"id":"c32c9aa9-cb22-580e-a212-fef5ed1d9346","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c32c9aa9-cb22-580e-a212-fef5ed1d9346/attachment.go","path":"internal/api/client_test.go","size":15184,"sha256":"df435f4d594ccbc9e496e7504b950589816d08de6dc96d1c191308f50295fca8","contentType":"text/plain; charset=utf-8"},{"id":"093b74ce-a6eb-52ab-a450-ac9e957b6124","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/093b74ce-a6eb-52ab-a450-ac9e957b6124/attachment.go","path":"internal/api/communications.go","size":5245,"sha256":"a9a7b88c3cb0ba5d01e7cd81452cbde7e2b16b6a2ecb10e966b28c01327cedd4","contentType":"text/plain; charset=utf-8"},{"id":"1c91164d-57b5-5cbf-9ca7-e0099bc36e35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1c91164d-57b5-5cbf-9ca7-e0099bc36e35/attachment.go","path":"internal/api/communications_paths_test.go","size":10710,"sha256":"d77973c7ad3aba39d0f5827f7191304b126e979dd01918275b18d16c301216b6","contentType":"text/plain; charset=utf-8"},{"id":"227f26de-8439-5e3a-8390-af0b5035817a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/227f26de-8439-5e3a-8390-af0b5035817a/attachment.go","path":"internal/api/communications_test.go","size":12110,"sha256":"872bbb6bdfa65e126537f632d24be1cf6e02ab6929c0fa8d36a45f3a5d81de75","contentType":"text/plain; charset=utf-8"},{"id":"57a36ba0-f87e-5867-b2d8-f494bafd6fec","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/57a36ba0-f87e-5867-b2d8-f494bafd6fec/attachment.go","path":"internal/api/events.go","size":5275,"sha256":"fcb8fd14a1f36627bf21abd8f74024beaf7ef35255500ed4e78a994b0fed5083","contentType":"text/plain; charset=utf-8"},{"id":"410b72ae-25e2-5765-a5ed-991cd7131664","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/410b72ae-25e2-5765-a5ed-991cd7131664/attachment.go","path":"internal/api/events_test.go","size":19804,"sha256":"63a0186f8e21ab98d422b74501e246cf5cf1b6a14a3a3a4715903bfc51009bc8","contentType":"text/plain; charset=utf-8"},{"id":"7a738038-d77d-5b4a-b5c9-8048405f3879","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7a738038-d77d-5b4a-b5c9-8048405f3879/attachment.go","path":"internal/api/exchange.go","size":2308,"sha256":"9eea9389a03d45203dd350115540868d0384c3e2490deef8125185d011e0de43","contentType":"text/plain; charset=utf-8"},{"id":"0af0fc90-0b74-5eaa-b38f-2b6164a70624","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0af0fc90-0b74-5eaa-b38f-2b6164a70624/attachment.go","path":"internal/api/exchange_test.go","size":11560,"sha256":"4ca03e70404e1d1f79523d93a6c5d14a36793f7826d9d342168748f8b431a839","contentType":"text/plain; charset=utf-8"},{"id":"359c757f-1e55-5b77-8048-3dfe000b1246","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/359c757f-1e55-5b77-8048-3dfe000b1246/attachment.go","path":"internal/api/markets.go","size":8004,"sha256":"e3c551c72fb79de99252b5e3424af4eee6af083e2d1e6e559baa55f92fedf0ba","contentType":"text/plain; charset=utf-8"},{"id":"e42d2d2d-82e9-5cde-97d1-c03815240c8c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e42d2d2d-82e9-5cde-97d1-c03815240c8c/attachment.go","path":"internal/api/markets_test.go","size":26916,"sha256":"ede6029423f3d3d831c820801512f8f421e45952e2db4b018b964894663dca2f","contentType":"text/plain; charset=utf-8"},{"id":"afe00e45-2abd-530e-a626-711beecd63e0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/afe00e45-2abd-530e-a626-711beecd63e0/attachment.go","path":"internal/api/milestones.go","size":3022,"sha256":"3a21c6e81f521e605a67c319eed81adb5fdde96cbcca5e0e18ba77145f9096cc","contentType":"text/plain; charset=utf-8"},{"id":"9c2e5865-528c-5eb7-abb2-a1c4014b5ce9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9c2e5865-528c-5eb7-abb2-a1c4014b5ce9/attachment.go","path":"internal/api/milestones_test.go","size":9274,"sha256":"f5c8c35771ec235f5a8fae1ccaca1e30aabc413a96af92f1ecd8697c5a682283","contentType":"text/plain; charset=utf-8"},{"id":"fc8c058b-cc99-5e54-a4ae-acb3826d9921","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fc8c058b-cc99-5e54-a4ae-acb3826d9921/attachment.go","path":"internal/api/multivariate.go","size":4813,"sha256":"62de345ffa778b2075d4f0ed8c156ce13134d36143a194ffe1ed3038d87262eb","contentType":"text/plain; charset=utf-8"},{"id":"109139d3-ffe4-53ad-a603-ed59c67d87e4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/109139d3-ffe4-53ad-a603-ed59c67d87e4/attachment.go","path":"internal/api/multivariate_test.go","size":14514,"sha256":"864424528258791fb2976f85d94d0c1df3ca0c32071fd63adf20d053cbd2f1a7","contentType":"text/plain; charset=utf-8"},{"id":"c7eb97a5-9aae-530d-86c4-843dc0dfe86b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c7eb97a5-9aae-530d-86c4-843dc0dfe86b/attachment.go","path":"internal/api/ordergroups.go","size":3648,"sha256":"58e583f96e9e9ffa1510227afbb85480a7ba97f9e765868491554cefd2a8052d","contentType":"text/plain; charset=utf-8"},{"id":"7396ea59-14af-5950-a26e-aba27c37a6f5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7396ea59-14af-5950-a26e-aba27c37a6f5/attachment.go","path":"internal/api/ordergroups_test.go","size":14674,"sha256":"313cdb97b2bb98a7ff01ed3bd44a61c5cd5fe237e2daabaf1fc69264895ad294","contentType":"text/plain; charset=utf-8"},{"id":"d36b102f-8af9-5591-9b2a-430804d04db5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d36b102f-8af9-5591-9b2a-430804d04db5/attachment.go","path":"internal/api/orders.go","size":5694,"sha256":"d8e1efd0224984a0130a01c43daa0b5b19ee6fa3f625a9c57a864b6bbd04dc99","contentType":"text/plain; charset=utf-8"},{"id":"d0d9435d-e7ab-56bf-bc05-1e7ac8e19c8e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d0d9435d-e7ab-56bf-bc05-1e7ac8e19c8e/attachment.go","path":"internal/api/orders_api_compliance_test.go","size":9728,"sha256":"5569003dfcbbea00486795c04b5dd846b0352a36b4bab4b8cf2a09d86cb365ac","contentType":"text/plain; charset=utf-8"},{"id":"3888f241-c5cb-55b7-a333-080e101d7d0e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3888f241-c5cb-55b7-a333-080e101d7d0e/attachment.go","path":"internal/api/orders_test.go","size":13292,"sha256":"6ce7c227927602c200968229c3c0a09a384a0b29cb5866418ae4ff915d58be26","contentType":"text/plain; charset=utf-8"},{"id":"4fb2337a-c9f3-5caf-88d6-e105a68aba84","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4fb2337a-c9f3-5caf-88d6-e105a68aba84/attachment.go","path":"internal/api/portfolio.go","size":5992,"sha256":"91698edf82cd95379f5374b1edc1562f1cfc59e616fd6c64f8fff73ddd06e6cb","contentType":"text/plain; charset=utf-8"},{"id":"e67047db-a2c2-5a87-a786-cfe783ee9b9b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e67047db-a2c2-5a87-a786-cfe783ee9b9b/attachment.go","path":"internal/api/portfolio_audit_test.go","size":9907,"sha256":"a61d35a9b82adcab673f66bc53ffb380c02f552884da0d15d3d0f459f436ea01","contentType":"text/plain; charset=utf-8"},{"id":"d02e46bd-f9fc-5481-b695-607a8a203727","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d02e46bd-f9fc-5481-b695-607a8a203727/attachment.go","path":"internal/api/portfolio_test.go","size":15231,"sha256":"918fe92d7271944f6a5092229db455a30ef535594f8051497e651f53089a4c9a","contentType":"text/plain; charset=utf-8"},{"id":"3a9b3338-02bb-51c2-a169-2b7c75c08c7a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3a9b3338-02bb-51c2-a169-2b7c75c08c7a/attachment.go","path":"internal/api/search.go","size":3196,"sha256":"829c82de7cc20aa1d0a62f0a7af97d435df040eca4dd6e05546642d66c76aeca","contentType":"text/plain; charset=utf-8"},{"id":"a4057876-6048-5b79-a232-04b1d0fc33ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a4057876-6048-5b79-a232-04b1d0fc33ad/attachment.go","path":"internal/api/search_test.go","size":11300,"sha256":"b61c2c5992d4e44e8fcf9582e4129aa32b983cbbf23ce6ca46017992752ad197","contentType":"text/plain; charset=utf-8"},{"id":"6547b841-281c-560c-bbc6-cf7579a4a902","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6547b841-281c-560c-bbc6-cf7579a4a902/attachment.go","path":"internal/cmd/auth.go","size":16821,"sha256":"3c94107779f294ac8c0202fd30f1a4773dda2f1b8f8ac203de774b6951789593","contentType":"text/plain; charset=utf-8"},{"id":"6c7003bd-7c95-5dcf-b792-f1a11aa129ab","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6c7003bd-7c95-5dcf-b792-f1a11aa129ab/attachment.go","path":"internal/cmd/config.go","size":6786,"sha256":"96f53ce9a0115f8c0309e44b2642b0c2dffa96a831607ada69435fce0198953f","contentType":"text/plain; charset=utf-8"},{"id":"860b1168-ea6b-59cf-be35-79d7bd866553","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/860b1168-ea6b-59cf-be35-79d7bd866553/attachment.go","path":"internal/cmd/events.go","size":14290,"sha256":"06116429a4de569e0351e8044dbafe3bb956a79b81773fc35409980955a604d1","contentType":"text/plain; charset=utf-8"},{"id":"8d6100c7-5e2b-5fd7-ab56-5284120efa80","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8d6100c7-5e2b-5fd7-ab56-5284120efa80/attachment.go","path":"internal/cmd/events_resolve_test.go","size":4183,"sha256":"a6ae698e734cde252850061364a0b1b984bbfe4cf54767f5b38eba03df03c6f7","contentType":"text/plain; charset=utf-8"},{"id":"ad1e49aa-de2a-5879-a168-271320c6b1b1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ad1e49aa-de2a-5879-a168-271320c6b1b1/attachment.go","path":"internal/cmd/exchange.go","size":7815,"sha256":"9c3c70e7cf71bff6cf09de7fa7bdfe833e8b96d1c38dff6475aec1d67b82c696","contentType":"text/plain; charset=utf-8"},{"id":"90c312f6-f70e-5383-b038-26a366a201c3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/90c312f6-f70e-5383-b038-26a366a201c3/attachment.go","path":"internal/cmd/helpers.go","size":3725,"sha256":"371e3c1d8eda6f4145f8dd37eede6b2c74ba42175b185efdcb64050c9499e25a","contentType":"text/plain; charset=utf-8"},{"id":"9944ed85-c3b3-579d-8171-4b053d4de561","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9944ed85-c3b3-579d-8171-4b053d4de561/attachment.go","path":"internal/cmd/markets.go","size":14932,"sha256":"05414420b744225a9b81ac478dc285cb6b3d6f8720696c555c1578e758b610eb","contentType":"text/plain; charset=utf-8"},{"id":"f6604efe-8f13-510f-8190-d38511f110d2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f6604efe-8f13-510f-8190-d38511f110d2/attachment.go","path":"internal/cmd/ordergroups.go","size":7944,"sha256":"b1011d46b419fb57d61b93a38437da33aad402f2b509bd807bfe52b0cc43c4d7","contentType":"text/plain; charset=utf-8"},{"id":"a0a0de6a-cf30-574f-8376-75002e95dae0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a0a0de6a-cf30-574f-8376-75002e95dae0/attachment.go","path":"internal/cmd/orders.go","size":20928,"sha256":"9f35011a6bb68500a05998e44c89c6b72de63e90476c3398b28aec0d7f2da32e","contentType":"text/plain; charset=utf-8"},{"id":"17af98d4-7d41-5445-9c3d-311b20afdf3c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/17af98d4-7d41-5445-9c3d-311b20afdf3c/attachment.go","path":"internal/cmd/portfolio.go","size":13048,"sha256":"a17655b266ec4514020ab5846fb6c3ed60c97f6c47b092df857a4f7a01c7f1ef","contentType":"text/plain; charset=utf-8"},{"id":"1ccb46bd-8c63-583e-adc4-c3f4bae4b3af","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1ccb46bd-8c63-583e-adc4-c3f4bae4b3af/attachment.go","path":"internal/cmd/rfq.go","size":11385,"sha256":"8036234f49d54541fb719d0fdbe4272cd4323c736fb27f24d8460812cf8d396d","contentType":"text/plain; charset=utf-8"},{"id":"607990bd-3620-5e59-94a2-40d8eaf71279","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/607990bd-3620-5e59-94a2-40d8eaf71279/attachment.go","path":"internal/cmd/root.go","size":3175,"sha256":"a8cdf11cdcfa18c288712bd3406bc6b2e28a5e7f5a35d10b99cbd41cb9adab41","contentType":"text/plain; charset=utf-8"},{"id":"758ca4ed-4ad2-51da-9f97-9d0cfe9ca789","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/758ca4ed-4ad2-51da-9f97-9d0cfe9ca789/attachment.go","path":"internal/cmd/watch.go","size":21466,"sha256":"b3c7a7350a6e962ea30269fe7384140854dfaf814f3a0c863d77119e46dc44dd","contentType":"text/plain; charset=utf-8"},{"id":"184a89cd-f65e-5264-a163-24af1627e5ea","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/184a89cd-f65e-5264-a163-24af1627e5ea/attachment.go","path":"internal/cmd/watch_test.go","size":4110,"sha256":"14559fcc473a5649eacfe615900b8d9638b50bc8cf8144ace473b2c3ec2051e0","contentType":"text/plain; charset=utf-8"},{"id":"2a62d04c-eff8-575a-a3ee-4a537c1f2e4d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2a62d04c-eff8-575a-a3ee-4a537c1f2e4d/attachment.go","path":"internal/config/config.go","size":2988,"sha256":"12931a1d0e5e274d018847e70c606edddb5adf6126e7ca95ca72a2d7e1ad91b9","contentType":"text/plain; charset=utf-8"},{"id":"f11e7747-c9d6-537e-8ad5-d8c004601776","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f11e7747-c9d6-537e-8ad5-d8c004601776/attachment.go","path":"internal/config/keyring.go","size":1708,"sha256":"749ae47e2fe0e15fe2d7da67c34a53e3f6a664798a389fffb1056b1ae05cd405","contentType":"text/plain; charset=utf-8"},{"id":"14e7ffbc-314e-570f-a360-0d552b662ca0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/14e7ffbc-314e-570f-a360-0d552b662ca0/attachment.go","path":"internal/ui/chart.go","size":6464,"sha256":"a7ac288247f5538e5ab77bcd9859746d9c65e9c7b01de3d5c75a2429fe59889d","contentType":"text/plain; charset=utf-8"},{"id":"1dab18e6-b70e-55e0-b16a-621900c3e3e9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1dab18e6-b70e-55e0-b16a-621900c3e3e9/attachment.go","path":"internal/ui/chart_test.go","size":4712,"sha256":"bdd40dac3038ad4b02d56fc9f6389d6288f7e93f44ab50b34edfe44d2624624e","contentType":"text/plain; charset=utf-8"},{"id":"c1623076-112c-5213-b3d0-f379d17c982c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c1623076-112c-5213-b3d0-f379d17c982c/attachment.go","path":"internal/ui/json.go","size":787,"sha256":"25ba10e06673f8bc902d3a1bb54e6e9fe5214c02b07e96cd86990634540ab586","contentType":"text/plain; charset=utf-8"},{"id":"43ebd3bf-cdc8-557d-9e57-b3c0dc8fe6c5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/43ebd3bf-cdc8-557d-9e57-b3c0dc8fe6c5/attachment.go","path":"internal/ui/styles.go","size":2295,"sha256":"1f8a7e5072d05b56f27a58e1e27ea157b4f08fb4fd435d23be712a866c5f392d","contentType":"text/plain; charset=utf-8"},{"id":"42820b76-d679-5aa7-840d-ddacadd263c9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/42820b76-d679-5aa7-840d-ddacadd263c9/attachment.go","path":"internal/ui/table.go","size":992,"sha256":"dd3f0534ae7f3dc8ea4fdd8b488e645e75becf0663412fe7c4c3c22279bcb1b0","contentType":"text/plain; charset=utf-8"},{"id":"a5e373f2-aa88-5db3-95ca-f62a973cd46b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a5e373f2-aa88-5db3-95ca-f62a973cd46b/attachment.go","path":"internal/websocket/channels.go","size":4053,"sha256":"fda4ac715a9c5b8850d5c25e86f92d65327b40dac3d2628099ed20ec9bcf5a0b","contentType":"text/plain; charset=utf-8"},{"id":"02179451-ae79-50bd-a411-05155a6db992","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/02179451-ae79-50bd-a411-05155a6db992/attachment.go","path":"internal/websocket/channels_audit_test.go","size":5157,"sha256":"aabe56923626004ccd5dcadc4058f0a449df7b494472c9a1a30e33bf06d16ec7","contentType":"text/plain; charset=utf-8"},{"id":"b9fac600-8ca5-54b6-b6a7-b94e40f69b77","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b9fac600-8ca5-54b6-b6a7-b94e40f69b77/attachment.go","path":"internal/websocket/channels_test.go","size":5630,"sha256":"431c6764926cba6d1c703ae9edca184055cd27997c7c7476039fdc13ecd811c6","contentType":"text/plain; charset=utf-8"},{"id":"ca594986-aabe-568c-925e-90aab99a0bad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ca594986-aabe-568c-925e-90aab99a0bad/attachment.go","path":"internal/websocket/client.go","size":13500,"sha256":"3c1ae72c6e69e8da9566876af433a35e9fedbe6e6a5cb4e9ec52d228b75e7e97","contentType":"text/plain; charset=utf-8"},{"id":"0d6036fb-e454-54d8-8216-d1f051004f8e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0d6036fb-e454-54d8-8216-d1f051004f8e/attachment.go","path":"internal/websocket/client_test.go","size":15036,"sha256":"cb0c5cf25e8a2472ffcf32a61ed71ff33478699abc4ef88a4bd1fbf894db7775","contentType":"text/plain; charset=utf-8"},{"id":"362e1e46-23a1-5e62-adad-7c052539bed7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/362e1e46-23a1-5e62-adad-7c052539bed7/attachment.go","path":"internal/websocket/handlers.go","size":3191,"sha256":"89b4b25fc4c1b15a94dab060b9b55b16847165f641a004d76cf336484fae7ca0","contentType":"text/plain; charset=utf-8"},{"id":"f3c8c028-2557-52e6-8536-a6c2e513ed88","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f3c8c028-2557-52e6-8536-a6c2e513ed88/attachment.go","path":"internal/websocket/handlers_test.go","size":4864,"sha256":"103c3f6466cb889f9ef6583d530bce75f92b89a77b3814dac633bccc56e892ab","contentType":"text/plain; charset=utf-8"},{"id":"8abad733-0acf-5b37-9c48-40b41c2ad9ab","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8abad733-0acf-5b37-9c48-40b41c2ad9ab/attachment.go","path":"pkg/models/apikey.go","size":1362,"sha256":"63e611b2d6e4def5f456519ab1f4a6a53f273bab6dfdf326a045acb06618bb58","contentType":"text/plain; charset=utf-8"},{"id":"c52964e7-5bf8-5cf5-a5e5-3522461fefdf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c52964e7-5bf8-5cf5-a5e5-3522461fefdf/attachment.go","path":"pkg/models/event.go","size":2397,"sha256":"edee3996d25dfb96e3287abfd6d348100ea71f1f764bf1bb6dcfbbd825abb42d","contentType":"text/plain; charset=utf-8"},{"id":"09503b03-cabd-57f0-b248-f30312401f72","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/09503b03-cabd-57f0-b248-f30312401f72/attachment.go","path":"pkg/models/exchange.go","size":3082,"sha256":"b20a1ed3fca3a4c875b158d2e63708c2b83c20dbd891e07c9bac32c537af9fb6","contentType":"text/plain; charset=utf-8"},{"id":"4f521507-3462-5296-b228-d17dfe00816b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f521507-3462-5296-b228-d17dfe00816b/attachment.go","path":"pkg/models/market.go","size":7487,"sha256":"90f8bc3ff36adc70132279e81563b67b2e19ecf9f904cd39ecff8b2381f9e17c","contentType":"text/plain; charset=utf-8"},{"id":"e15ac74e-428c-57fe-968a-f595eb19c33a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e15ac74e-428c-57fe-968a-f595eb19c33a/attachment.go","path":"pkg/models/milestone.go","size":1367,"sha256":"de6fec0a6460e885c5367d61a052f30c26e001ef059f11183ca907fc3f740871","contentType":"text/plain; charset=utf-8"},{"id":"80859d01-dfcd-506c-9eee-d69cc45dd5c7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/80859d01-dfcd-506c-9eee-d69cc45dd5c7/attachment.go","path":"pkg/models/multivariate.go","size":1753,"sha256":"23f1d217334f59ed25f7fe5afd159541c26e07da3a30f089d784a60f41f2ec0e","contentType":"text/plain; charset=utf-8"},{"id":"6eff1030-1a41-5cda-96db-44dfcd6e5268","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6eff1030-1a41-5cda-96db-44dfcd6e5268/attachment.go","path":"pkg/models/order.go","size":4896,"sha256":"1bf0e3659dd35137cf6bd24fe88bf5754e309f8de1e5bd070d31beeb2a20e65f","contentType":"text/plain; charset=utf-8"},{"id":"b8eb0ba0-3aa0-5983-aaab-728a70ca7929","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8eb0ba0-3aa0-5983-aaab-728a70ca7929/attachment.go","path":"pkg/models/ordergroup.go","size":1253,"sha256":"0de5895fdf48c757609a16671cbf127f0dd08a223b99661798230c1eef0d0e42","contentType":"text/plain; charset=utf-8"},{"id":"b7ad9d9c-5ddf-5954-9599-9567441a9e71","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b7ad9d9c-5ddf-5954-9599-9567441a9e71/attachment.go","path":"pkg/models/position.go","size":4923,"sha256":"5ac9ca719da4c2b5f5b63f2db77588e2b5fef23533e318df8a895ef813c117dd","contentType":"text/plain; charset=utf-8"},{"id":"c2297c88-2b9a-59a3-abba-1c48e24d5777","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c2297c88-2b9a-59a3-abba-1c48e24d5777/attachment.go","path":"pkg/models/rfq.go","size":3121,"sha256":"60ec2b292e14d162a2526c94e417e891258443d2dc70c6d4ec2b2bbfe5fcaeae","contentType":"text/plain; charset=utf-8"},{"id":"1db49bba-2a22-5018-85e6-af1e9ebbd9f5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1db49bba-2a22-5018-85e6-af1e9ebbd9f5/attachment.go","path":"pkg/models/search.go","size":1837,"sha256":"b47e60ff5b5739d99b871c436db9aadc06f43a958910145e18e990cb72953fd6","contentType":"text/plain; charset=utf-8"},{"id":"b3e65540-a96b-5ad5-8235-a933d86e82d1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b3e65540-a96b-5ad5-8235-a933d86e82d1/attachment.md","path":"references/auth.md","size":2715,"sha256":"1e64cd99895c615ef7ea3178391db1b8b626309a20c5df6eaec2a9d8ae8921c7","contentType":"text/markdown; charset=utf-8"},{"id":"5c536501-a9a0-5613-8150-3a80f8ec2c35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5c536501-a9a0-5613-8150-3a80f8ec2c35/attachment.md","path":"references/config.md","size":1817,"sha256":"efd6135197cf119a46b2c9fbcd97a1ad243b4800f63d6da3719a1adb6ed2b995","contentType":"text/markdown; charset=utf-8"},{"id":"905fc5fd-59e6-5d08-abe7-9812fc7e7ce1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/905fc5fd-59e6-5d08-abe7-9812fc7e7ce1/attachment.md","path":"references/events.md","size":2285,"sha256":"55544efa1567923dfd9135197716273ab585c35b287c4b35499507531cb92bcb","contentType":"text/markdown; charset=utf-8"},{"id":"22c571e5-dbc4-5e78-a989-9ab5ba1e16ae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/22c571e5-dbc4-5e78-a989-9ab5ba1e16ae/attachment.md","path":"references/exchange.md","size":1394,"sha256":"b71932c06b488dbb63899a90327be66f60005037d40eb082a4fcef973af98b75","contentType":"text/markdown; charset=utf-8"},{"id":"ff678989-8861-5954-b7c1-ae9191da4250","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ff678989-8861-5954-b7c1-ae9191da4250/attachment.md","path":"references/markets.md","size":2854,"sha256":"ea887015084d7c5dcd4e892e721b10f290970f620af91f5da50c474dda00861a","contentType":"text/markdown; charset=utf-8"},{"id":"facc6ff0-a273-531c-925c-efa75270e013","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/facc6ff0-a273-531c-925c-efa75270e013/attachment.md","path":"references/models.md","size":4159,"sha256":"998f040400c8f0fb5950dc98f6d87ed1549712e3772b2ad8da208fe4a456bce7","contentType":"text/markdown; charset=utf-8"},{"id":"10da3e25-7042-5f82-be71-d396ee0dbec8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/10da3e25-7042-5f82-be71-d396ee0dbec8/attachment.md","path":"references/order-groups.md","size":1937,"sha256":"9e0856330a35deab92c30e9e30648fae70ad8a400c1badfe6438c95901742e21","contentType":"text/markdown; charset=utf-8"},{"id":"5c68eaf7-3207-50fd-92d0-d4376e9c9f83","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5c68eaf7-3207-50fd-92d0-d4376e9c9f83/attachment.md","path":"references/orders.md","size":3315,"sha256":"45b2eda523ff54c7f766ad2a5dbcbb8e8f7039173a3a9c7754515ea3254eb32f","contentType":"text/markdown; charset=utf-8"},{"id":"5f6d0aef-4d47-54ec-b9b3-6fa8c2ea9a14","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5f6d0aef-4d47-54ec-b9b3-6fa8c2ea9a14/attachment.md","path":"references/portfolio.md","size":2142,"sha256":"ed027a596eabd50796ba0c4e56416cfd014232ca4be2654ef8702b2877ded97b","contentType":"text/markdown; charset=utf-8"},{"id":"1cf191d1-aac1-56b3-a876-9114b02f760f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1cf191d1-aac1-56b3-a876-9114b02f760f/attachment.md","path":"references/rfq-quotes.md","size":1840,"sha256":"a57929e4ed0c2a0d4916af314219ea101e08210d571bc38bac94bc6fb0092964","contentType":"text/markdown; charset=utf-8"},{"id":"54328f93-f3f0-55c2-a55d-716dc806fdcb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/54328f93-f3f0-55c2-a55d-716dc806fdcb/attachment.md","path":"references/watch.md","size":2153,"sha256":"7716cdb60c194d4f6e2c6dc883228629f2b814846b9b1fc505cb713ff9ae5b41","contentType":"text/markdown; charset=utf-8"}],"bundle_sha256":"05bd1525b99a348d153bc9e3020e922368e929133b1d4bbae5fe360c948e34a6","attachment_count":88,"text_attachments":88,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"web-development","category_label":"Web"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"web-development","metadata":{"author":"6missedcalls","version":"1.0"},"import_tag":"clean-skills-v1","description":"Comprehensive CLI for the Kalshi prediction market exchange. Provides trading, portfolio management, market data, real-time WebSocket streaming, and account management. Use when building trading workflows, querying markets/events, managing orders/positions, or automating Kalshi API interactions.","compatibility":"Requires Go 1.21+. Uses system keyring for credential storage."}},"renderedAt":1782986955631}

kalshi-cli A Go CLI (Cobra + Viper) for the Kalshi prediction market exchange API v2. Binary: . When to use Use this skill when: - Creating, amending, or canceling trading orders on Kalshi - Querying market data, events, series, or orderbooks - Managing portfolio positions, fills, settlements, or subaccounts - Streaming real-time data via WebSocket (ticker, orderbook, trades, orders, fills, positions) - Managing authentication credentials and API keys - Working with RFQs (Request for Quotes) and block trading - Managing order groups for grouped order execution - Checking exchange status, sche…