diff --git a/README.md b/README.md index a2ee422..1280b71 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ -# tastytrade +# Go Tastytrade Open API Wrapper + Go API wrapper for the Tastytrade Open API + +![Build](https://github.com/optionsvamp/tastytrade/actions/workflows/build.yaml/badge.svg) + +## Table of Contents +1. [Introduction](#introduction) +2. [Installation](#installation) +3. [Usage](#usage) +4. [Testing](#testing) +5. [Contributing](#contributing) +6. [License](#license) + +## Introduction + +This project provides a Go wrapper for the Tastytrade Open API. It allows developers to interact with Tastytrade's financial data and services in a more Go-idiomatic way, abstracting away the details of direct HTTP requests and responses. + +## Installation + +To install this project, you can use `go get`: + +```bash +go get github.com/optionsvamp/tastytrade +``` + +Then, import it in your Go code: + +``` +import "github.com/optionsvamp/tastytrade" +``` + +## Usage + +Here's a basic example of how to use this wrapper to get account balances: + +``` +api := NewTastytradeAPI("your-api-key") +balances, err := api.GetAccountBalances("your-account-number") +if err != nil { + log.Fatal(err) +} +fmt.Println(balances) +``` + +## Testing + +To run the tests for this project, you can use go test: + +```bash +go test ./... +``` + +## Contributing + +Contributions to this project are welcome! Please submit a pull request or open an issue on GitHub. + +## License + +This project is released into the public domain under the Unlicense. For more information, please refer to the LICENSE file or visit https://unlicense.org. \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..c498e13 --- /dev/null +++ b/api.go @@ -0,0 +1,86 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +const ( + baseURL = "https://api.tastytrade.com" +) + +// TastytradeAPI represents the Tastytrade API client +type TastytradeAPI struct { + httpClient *http.Client + authToken string + host string +} + +// NewTastytradeAPI creates a new instance of TastytradeAPI +func NewTastytradeAPI(hosts ...string) *TastytradeAPI { + host := baseURL + if len(hosts) > 0 { + host = hosts[0] + } + return &TastytradeAPI{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + host: host, + } +} + +// fetchData sends a GET request to the specified URL with authorization +func (api *TastytradeAPI) fetchData(url string) (map[string]interface{}, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", api.authToken) + + resp, err := api.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, fmt.Errorf("client error occurred: status code %d", resp.StatusCode) + } else if resp.StatusCode >= 500 { + return nil, fmt.Errorf("server error occurred: status code %d", resp.StatusCode) + } + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return data, nil +} + +// Helper function to fetch and unmarshal data +func (api *TastytradeAPI) fetchDataAndUnmarshal(urlVal string, v interface{}) error { + req, err := http.NewRequest("GET", urlVal, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", api.authToken) + + resp, err := api.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return fmt.Errorf("client error occurred: status code %d", resp.StatusCode) + } else if resp.StatusCode >= 500 { + return fmt.Errorf("server error occurred: status code %d", resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return err + } + + return nil +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..75b7f95 --- /dev/null +++ b/api_test.go @@ -0,0 +1,44 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"data": "test"}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.fetchData(server.URL) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp["data"] != "test" { + t.Errorf("expected %s, got %s", "test", resp["data"]) + } +} + +func TestFetchDataAndUnmarshal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"data": "test"}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + var v map[string]interface{} + err := api.fetchDataAndUnmarshal(server.URL, &v) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if v["data"] != "test" { + t.Errorf("expected %s, got %s", "test", v["data"]) + } +} diff --git a/balances.go b/balances.go new file mode 100644 index 0000000..75a6aa1 --- /dev/null +++ b/balances.go @@ -0,0 +1,118 @@ +package tastytrade + +import ( + "fmt" +) + +type BalanceData struct { + AccountNumber string `json:"account-number"` + CashBalance float64 `json:"cash-balance,string"` + LongEquityValue float64 `json:"long-equity-value,string"` + ShortEquityValue float64 `json:"short-equity-value,string"` + LongDerivativeValue float64 `json:"long-derivative-value,string"` + ShortDerivativeValue float64 `json:"short-derivative-value,string"` + LongFuturesValue float64 `json:"long-futures-value,string"` + ShortFuturesValue float64 `json:"short-futures-value,string"` + LongFuturesDerivativeValue float64 `json:"long-futures-derivative-value,string"` + ShortFuturesDerivativeValue float64 `json:"short-futures-derivative-value,string"` + LongMargineableValue float64 `json:"long-margineable-value,string"` + ShortMargineableValue float64 `json:"short-margineable-value,string"` + MarginEquity float64 `json:"margin-equity,string"` + EquityBuyingPower float64 `json:"equity-buying-power,string"` + DerivativeBuyingPower float64 `json:"derivative-buying-power,string"` + DayTradingBuyingPower float64 `json:"day-trading-buying-power,string"` + FuturesMarginRequirement float64 `json:"futures-margin-requirement,string"` + AvailableTradingFunds float64 `json:"available-trading-funds,string"` + MaintenanceRequirement float64 `json:"maintenance-requirement,string"` + MaintenanceCallValue float64 `json:"maintenance-call-value,string"` + RegTCallValue float64 `json:"reg-t-call-value,string"` + DayTradingCallValue float64 `json:"day-trading-call-value,string"` + DayEquityCallValue float64 `json:"day-equity-call-value,string"` + NetLiquidatingValue float64 `json:"net-liquidating-value,string"` + CashAvailableToWithdraw float64 `json:"cash-available-to-withdraw,string"` + DayTradeExcess float64 `json:"day-trade-excess,string"` + PendingCash float64 `json:"pending-cash,string"` + PendingCashEffect string `json:"pending-cash-effect"` + LongCryptocurrencyValue float64 `json:"long-cryptocurrency-value,string"` + ShortCryptocurrencyValue float64 `json:"short-cryptocurrency-value,string"` + CryptocurrencyMarginRequirement float64 `json:"cryptocurrency-margin-requirement,string"` + UnsettledCryptocurrencyFiatAmount float64 `json:"unsettled-cryptocurrency-fiat-amount,string"` + UnsettledCryptocurrencyFiatEffect string `json:"unsettled-cryptocurrency-fiat-effect"` + ClosedLoopAvailableBalance float64 `json:"closed-loop-available-balance,string"` + EquityOfferingMarginRequirement float64 `json:"equity-offering-margin-requirement,string"` + LongBondValue float64 `json:"long-bond-value,string"` + BondMarginRequirement float64 `json:"bond-margin-requirement,string"` + UsedDerivativeBuyingPower float64 `json:"used-derivative-buying-power,string"` + SnapshotDate string `json:"snapshot-date"` + RegTMarginRequirement float64 `json:"reg-t-margin-requirement,string"` + FuturesOvernightMarginRequirement float64 `json:"futures-overnight-margin-requirement,string"` + FuturesIntradayMarginRequirement float64 `json:"futures-intraday-margin-requirement,string"` + MaintenanceExcess float64 `json:"maintenance-excess,string"` + PendingMarginInterest float64 `json:"pending-margin-interest,string"` + EffectiveCryptocurrencyBuyingPower float64 `json:"effective-cryptocurrency-buying-power,string"` + UpdatedAt string `json:"updated-at"` +} + +type BalanceResponse struct { + Data BalanceData `json:"data"` + Context string `json:"context"` +} + +type AccountBalanceSnapshot struct { + AccountNumber string `json:"account-number"` + CashBalance float64 `json:"cash-balance,string"` + LongEquityValue float64 `json:"long-equity-value,string"` + ShortEquityValue float64 `json:"short-equity-value,string"` + LongDerivativeValue float64 `json:"long-derivative-value,string"` + ShortDerivativeValue float64 `json:"short-derivative-value,string"` + LongFuturesValue float64 `json:"long-futures-value,string"` + ShortFuturesValue float64 `json:"short-futures-value,string"` + LongMargineableValue float64 `json:"long-margineable-value,string"` + ShortMargineableValue float64 `json:"short-margineable-value,string"` + MarginEquity float64 `json:"margin-equity,string"` + EquityBuyingPower float64 `json:"equity-buying-power,string"` + DerivativeBuyingPower float64 `json:"derivative-buying-power,string"` + DayTradingBuyingPower float64 `json:"day-trading-buying-power,string"` + FuturesMarginRequirement float64 `json:"futures-margin-requirement,string"` + AvailableTradingFunds float64 `json:"available-trading-funds,string"` + MaintenanceRequirement float64 `json:"maintenance-requirement,string"` + MaintenanceCallValue float64 `json:"maintenance-call-value,string"` + RegTCallValue float64 `json:"reg-t-call-value,string"` + DayTradingCallValue float64 `json:"day-trading-call-value,string"` + DayEquityCallValue float64 `json:"day-equity-call-value,string"` + NetLiquidatingValue float64 `json:"net-liquidating-value,string"` + DayTradeExcess float64 `json:"day-trade-excess,string"` + PendingCash float64 `json:"pending-cash,string"` + PendingCashEffect string `json:"pending-cash-effect"` + SnapshotDate string `json:"snapshot-date"` + TimeOfDay string `json:"time-of-day"` +} + +type AccountBalanceSnapshotResponse struct { + Data struct { + Items []AccountBalanceSnapshot `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +// GetAccountBalances retrieves balances for a specific account +func (api *TastytradeAPI) GetAccountBalances(accountNumber string) (BalanceResponse, error) { + url := fmt.Sprintf("%s/accounts/%s/balances", api.host, accountNumber) + var response BalanceResponse + err := api.fetchDataAndUnmarshal(url, &response) + if err != nil { + return BalanceResponse{}, err + } + return response, nil +} + +// GetAccountBalanceSnapshots retrieves balance snapshots for a specific account +func (api *TastytradeAPI) GetAccountBalanceSnapshots(accountNumber string, snapshotDate string, timeOfDay string) (AccountBalanceSnapshotResponse, error) { + url := fmt.Sprintf("%s/accounts/%s/balance-snapshots?snapshot-date=%s&time-of-day=%s", api.host, accountNumber, snapshotDate, timeOfDay) + var response AccountBalanceSnapshotResponse + err := api.fetchDataAndUnmarshal(url, &response) + if err != nil { + return AccountBalanceSnapshotResponse{}, err + } + return response, nil +} diff --git a/balances_test.go b/balances_test.go new file mode 100644 index 0000000..063777e --- /dev/null +++ b/balances_test.go @@ -0,0 +1,43 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetAccountBalances(t *testing.T) { + // Create a mock HTTP server + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + // Test request parameters + if req.URL.String() != "/accounts/123/balances" { + t.Errorf("got: %s, want: /accounts/123/balances", req.URL.String()) + } + // Send response to be tested + rw.Write([]byte(`{"data": {"account-number": "123", "cash-balance": "1000"}, "context": "test"}`)) + })) + // Close the server when test finishes + defer server.Close() + + // Initialize a new TastytradeAPI instance + api := NewTastytradeAPI(server.URL) + + // Invoke the method to be tested + resp, err := api.GetAccountBalances("123") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Data.AccountNumber != "123" { + t.Errorf("expected %s, got %s", "123", resp.Data.AccountNumber) + } + + if resp.Data.CashBalance != 1000 { + t.Errorf("expected %f, got %f", 1000.0, resp.Data.CashBalance) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } +} diff --git a/customer.go b/customer.go new file mode 100644 index 0000000..64b5374 --- /dev/null +++ b/customer.go @@ -0,0 +1,188 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" +) + +type Address struct { + StreetOne string `json:"street-one"` + City string `json:"city"` + StateRegion string `json:"state-region"` + PostalCode string `json:"postal-code"` + Country string `json:"country"` + IsForeign bool `json:"is-foreign"` + IsDomestic bool `json:"is-domestic"` +} + +type CustomerSuitability struct { + ID int `json:"id"` + MaritalStatus string `json:"marital-status"` + NumberOfDependents int `json:"number-of-dependents"` + EmploymentStatus string `json:"employment-status"` + Occupation string `json:"occupation"` + EmployerName string `json:"employer-name"` + JobTitle string `json:"job-title"` + AnnualNetIncome int `json:"annual-net-income"` + NetWorth int `json:"net-worth"` + LiquidNetWorth int `json:"liquid-net-worth"` + StockTradingExperience string `json:"stock-trading-experience"` + CoveredOptionsTradingExperience string `json:"covered-options-trading-experience"` + UncoveredOptionsTradingExperience string `json:"uncovered-options-trading-experience"` + FuturesTradingExperience string `json:"futures-trading-experience"` +} + +type Person struct { + ExternalID string `json:"external-id"` + FirstName string `json:"first-name"` + LastName string `json:"last-name"` + BirthDate string `json:"birth-date"` + CitizenshipCountry string `json:"citizenship-country"` + USACitizenshipType string `json:"usa-citizenship-type"` + MaritalStatus string `json:"marital-status"` + NumberOfDependents int `json:"number-of-dependents"` + EmploymentStatus string `json:"employment-status"` + Occupation string `json:"occupation"` + EmployerName string `json:"employer-name"` + JobTitle string `json:"job-title"` +} + +type CustomerData struct { + ID string `json:"id"` + FirstName string `json:"first-name"` + LastName string `json:"last-name"` + Address Address `json:"address"` + MailingAddress Address `json:"mailing-address"` + CustomerSuitability CustomerSuitability `json:"customer-suitability"` + USACitizenshipType string `json:"usa-citizenship-type"` + IsForeign bool `json:"is-foreign"` + MobilePhoneNumber string `json:"mobile-phone-number"` + Email string `json:"email"` + TaxNumberType string `json:"tax-number-type"` + TaxNumber string `json:"tax-number"` + BirthDate string `json:"birth-date"` + ExternalID string `json:"external-id"` + CitizenshipCountry string `json:"citizenship-country"` + SubjectToTaxWithholding bool `json:"subject-to-tax-withholding"` + AgreedToMargining bool `json:"agreed-to-margining"` + AgreedToTerms bool `json:"agreed-to-terms"` + HasIndustryAffiliation bool `json:"has-industry-affiliation"` + HasPoliticalAffiliation bool `json:"has-political-affiliation"` + HasListedAffiliation bool `json:"has-listed-affiliation"` + IsProfessional bool `json:"is-professional"` + HasDelayedQuotes bool `json:"has-delayed-quotes"` + HasPendingOrApprovedApplication bool `json:"has-pending-or-approved-application"` + IdentifiableType string `json:"identifiable-type"` + Person Person `json:"person"` +} + +type CustomerResponse struct { + Context string `json:"context"` + Data CustomerData `json:"data"` +} + +type Account struct { + AccountNumber string `json:"account-number"` + ExternalID string `json:"external-id"` + OpenedAt string `json:"opened-at"` + Nickname string `json:"nickname"` + AccountTypeName string `json:"account-type-name"` + DayTraderStatus bool `json:"day-trader-status"` + IsClosed bool `json:"is-closed"` + IsFirmError bool `json:"is-firm-error"` + IsFirmProprietary bool `json:"is-firm-proprietary"` + IsFuturesApproved bool `json:"is-futures-approved"` + IsTestDrive bool `json:"is-test-drive"` + MarginOrCash string `json:"margin-or-cash"` + IsForeign bool `json:"is-foreign"` + FundingDate string `json:"funding-date"` + InvestmentObjective string `json:"investment-objective"` + FuturesAccountPurpose string `json:"futures-account-purpose"` + SuitableOptionsLevel string `json:"suitable-options-level"` + CreatedAt string `json:"created-at"` +} + +type AccountContainer struct { + Account Account `json:"account"` + AuthorityLevel string `json:"authority-level"` +} + +type AccountData struct { + Items []AccountContainer `json:"items"` +} + +type AccountResponse struct { + Context string `json:"context"` + Data Account `json:"data"` +} + +type AccountsResponse struct { + Context string `json:"context"` + Data AccountData `json:"data"` +} + +// GetCustomerInfo retrieves customer information +func (api *TastytradeAPI) GetCustomerInfo() (CustomerResponse, error) { + url := fmt.Sprintf("%s/customers/me", api.host) + data, err := api.fetchData(url) + if err != nil { + return CustomerResponse{}, err + } + + var response CustomerResponse + jsonData, err := json.Marshal(data) + if err != nil { + return CustomerResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return CustomerResponse{}, err + } + + return response, nil +} + +// ListCustomerAccounts retrieves a list of customer accounts +func (api *TastytradeAPI) ListCustomerAccounts() (AccountsResponse, error) { + url := fmt.Sprintf("%s/customers/me/accounts", api.host) + data, err := api.fetchData(url) + if err != nil { + return AccountsResponse{}, err + } + + var response AccountsResponse + jsonData, err := json.Marshal(data) + if err != nil { + return AccountsResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return AccountsResponse{}, err + } + + return response, nil +} + +// GetAccount retrieves account information for a specific account +func (api *TastytradeAPI) GetAccount(accountNumber string) (AccountResponse, error) { + url := fmt.Sprintf("%s/customers/me/accounts/%s", api.host, accountNumber) + data, err := api.fetchData(url) + if err != nil { + return AccountResponse{}, err + } + + var response AccountResponse + jsonData, err := json.Marshal(data) + if err != nil { + return AccountResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return AccountResponse{}, err + } + + return response, nil +} diff --git a/customer_test.go b/customer_test.go new file mode 100644 index 0000000..acf026e --- /dev/null +++ b/customer_test.go @@ -0,0 +1,77 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetCustomerInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"id": "123", "first-name": "John", "last-name": "Doe"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetCustomerInfo() + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.ID != "123" { + t.Errorf("expected %s, got %s", "123", resp.Data.ID) + } +} + +func TestListCustomerAccounts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"account": {"account-number": "123"}}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListCustomerAccounts() + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Account.AccountNumber != "123" { + t.Errorf("expected %s, got %s", "123", resp.Data.Items[0].Account.AccountNumber) + } +} + +func TestGetAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"account-number": "123"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetAccount("123") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.AccountNumber != "123" { + t.Errorf("expected %s, got %s", "123", resp.Data.AccountNumber) + } +} diff --git a/equity.go b/equity.go new file mode 100644 index 0000000..37164f0 --- /dev/null +++ b/equity.go @@ -0,0 +1,159 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type EquityResponse struct { + Context string `json:"context"` + Data EquityData `json:"data"` +} + +type Tick struct { + Threshold string `json:"threshold,omitempty"` + Value string `json:"value"` +} + +type EquityData struct { + Active bool `json:"active"` + BorrowRate string `json:"borrow-rate"` + Cusip string `json:"cusip"` + Description string `json:"description"` + ID int `json:"id"` + InstrumentType string `json:"instrument-type"` + IsClosingOnly bool `json:"is-closing-only"` + IsETF bool `json:"is-etf"` + IsFractionalQuantityEligible bool `json:"is-fractional-quantity-eligible"` + IsIlliquid bool `json:"is-illiquid"` + IsIndex bool `json:"is-index"` + IsOptionsClosingOnly bool `json:"is-options-closing-only"` + Lendability string `json:"lendability"` + ListedMarket string `json:"listed-market"` + MarketTimeInstrumentCollection string `json:"market-time-instrument-collection"` + OptionTickSizes []Tick `json:"option-tick-sizes"` + ShortDescription string `json:"short-description"` + StreamerSymbol string `json:"streamer-symbol"` + Symbol string `json:"symbol"` + TickSizes []Tick `json:"tick-sizes"` +} + +type EquityListResponse struct { + Context string `json:"context"` + Data struct { + Items []EquityData `json:"items"` + } `json:"data"` +} + +type EquityQueryParams struct { + Symbol []string `json:"symbol"` + Lendability string `json:"lendability"` + IsIndex *bool `json:"is-index"` + IsETF *bool `json:"is-etf"` +} + +type ActiveEquityQueryParams struct { + Lendability string `json:"lendability"` + PerPage int `json:"per-page"` + PageOffset int `json:"page-offset"` +} + +// GetEquityData retrieves data for a specific equity symbol +func (api *TastytradeAPI) GetEquityData(symbol string) (EquityResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/equities/%s", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return EquityResponse{}, err + } + + var response EquityResponse + jsonData, err := json.Marshal(data) + if err != nil { + return EquityResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return EquityResponse{}, err + } + + return response, nil +} + +// ListEquities retrieves a list of all equities +func (api *TastytradeAPI) ListEquities(params *EquityQueryParams) (EquityListResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/equities", api.host) + + if params != nil { + queryParams := url.Values{} + for _, symbol := range params.Symbol { + queryParams.Add("symbol[]", symbol) + } + if params.Lendability != "" { + queryParams.Add("lendability", params.Lendability) + } + if params.IsIndex != nil { + queryParams.Add("is-index", fmt.Sprintf("%t", *params.IsIndex)) + } + if params.IsETF != nil { + queryParams.Add("is-etf", fmt.Sprintf("%t", *params.IsETF)) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return EquityListResponse{}, err + } + + var response EquityListResponse + jsonData, err := json.Marshal(data) + if err != nil { + return EquityListResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return EquityListResponse{}, err + } + + return response, nil +} + +// ListActiveEquities retrieves a list of all active equities +func (api *TastytradeAPI) ListActiveEquities(params *ActiveEquityQueryParams) (EquityListResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/equities/active", api.host) + + if params != nil { + queryParams := url.Values{} + if params.Lendability != "" { + queryParams.Add("lendability", params.Lendability) + } + if params.PerPage != 0 { + queryParams.Add("per-page", fmt.Sprintf("%d", params.PerPage)) + } + if params.PageOffset != 0 { + queryParams.Add("page-offset", fmt.Sprintf("%d", params.PageOffset)) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return EquityListResponse{}, err + } + + var response EquityListResponse + jsonData, err := json.Marshal(data) + if err != nil { + return EquityListResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return EquityListResponse{}, err + } + + return response, nil +} diff --git a/equity_test.go b/equity_test.go new file mode 100644 index 0000000..acdfe5e --- /dev/null +++ b/equity_test.go @@ -0,0 +1,73 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetEquityData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"symbol": "AAPL"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetEquityData("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Symbol) + } +} + +func TestListEquities(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL"}, {"symbol": "GOOG"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListEquities(nil) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 2 { + t.Errorf("expected %d, got %d", 2, len(resp.Data.Items)) + } +} + +func TestListActiveEquities(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL"}, {"symbol": "GOOG"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListActiveEquities(nil) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 2 { + t.Errorf("expected %d, got %d", 2, len(resp.Data.Items)) + } +} diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..c02809d --- /dev/null +++ b/example/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "github.com/optionsvamp/tastytrade" + "os" +) + +func main() { + // Example usage: + api := tastytrade.NewTastytradeAPI("") + + // Authenticate with Tastytrade API + err := api.Authenticate(os.Getenv("USER"), os.Getenv("PWD")) + if err != nil { + fmt.Println("Authentication failed:", err) + return + } + + //customerInfo, err := api.GetCustomerInfo() + //if err != nil { + // fmt.Println("Error fetching customer data:", err) + // return + //} + //fmt.Println("Customer data:", customerInfo) + + // Get a list of customer accounts + customerAccounts, err := api.ListCustomerAccounts() + if err != nil { + fmt.Println("Error fetching customer accounts:", err) + return + } + //fmt.Println("Customer accounts:", customerAccounts) + + // Get account trading status for the first account in the previous response + status, err := api.GetAccountTradingStatus(customerAccounts.Data.Items[0].Account.AccountNumber) + if err != nil { + fmt.Println("Error fetching customer account status:", err) + return + } + fmt.Println("Customer account status:", status) + + // Get account + acct, err := api.GetAccount(customerAccounts.Data.Items[0].Account.AccountNumber) + if err != nil { + fmt.Println("Error fetching customer account:", err) + return + } + fmt.Println("Customer account:", acct) + + // Get account balance + bal, err := api.GetAccountBalances(customerAccounts.Data.Items[0].Account.AccountNumber) + if err != nil { + fmt.Println("Error fetching account balance:", err) + return + } + fmt.Println("Account balance:", bal) + + snap, err := api.GetAccountBalanceSnapshots(customerAccounts.Data.Items[0].Account.AccountNumber, "2024-01-01", "BOD") + if err != nil { + fmt.Println("Error fetching account balance snapshot:", err) + return + } + fmt.Println("Account balance snapshot:", snap) + + // Get symbol data + symbolData, err := api.GetEquityData("AAPL") + if err != nil { + fmt.Println("Error fetching symbol data:", err) + return + } + fmt.Println("Symbol data:", symbolData) + + transactions, err := api.GetTransactions(customerAccounts.Data.Items[2].Account.AccountNumber, &tastytrade.TransactionQueryParams{ + Symbol: "/MCLN4", + InstrumentType: "Future", + }) + if err != nil { + fmt.Println("Error fetching transactions data:", err) + return + } + fmt.Println("Transactions data:", transactions) + + symbols, err := api.ListEquities(nil) + if err != nil { + fmt.Println("Error fetching symbol data:", err) + return + } + _ = symbols + + // Get option chain + optionChain, err := api.ListOptionsChainsDetailed("AAPL") + if err != nil { + fmt.Println("Error fetching option chain:", err) + return + } + fmt.Println("Option chain:", optionChain) +} diff --git a/futures.go b/futures.go new file mode 100644 index 0000000..103593a --- /dev/null +++ b/futures.go @@ -0,0 +1,492 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type FutureETFEquivalent struct { + Symbol string `json:"symbol"` + ShareQuantity int `json:"share-quantity"` +} + +type FutureProduct struct { + RootSymbol string `json:"root-symbol"` + Code string `json:"code"` + Description string `json:"description"` + ClearingCode string `json:"clearing-code"` + ClearingExchangeCode string `json:"clearing-exchange-code"` + ClearportCode string `json:"clearport-code"` + LegacyCode string `json:"legacy-code"` + Exchange string `json:"exchange"` + LegacyExchangeCode string `json:"legacy-exchange-code"` + ProductType string `json:"product-type"` + ListedMonths []string `json:"listed-months"` + ActiveMonths []string `json:"active-months"` + NotionalMultiplier string `json:"notional-multiplier"` + TickSize string `json:"tick-size"` + DisplayFactor string `json:"display-factor"` + StreamerExchangeCode string `json:"streamer-exchange-code"` + SmallNotional bool `json:"small-notional"` + BackMonthFirstCalendarSymbol bool `json:"back-month-first-calendar-symbol"` + FirstNotice bool `json:"first-notice"` + CashSettled bool `json:"cash-settled"` + SecurityGroup string `json:"security-group"` + MarketSector string `json:"market-sector"` + Roll struct { + Name string `json:"name"` + ActiveCount int `json:"active-count"` + CashSettled bool `json:"cash-settled"` + BusinessDaysOffset int `json:"business-days-offset"` + FirstNotice bool `json:"first-notice"` + } `json:"roll"` +} + +type TickSize struct { + Value string `json:"value"` + Threshold string `json:"threshold,omitempty"` + Symbol string `json:"symbol,omitempty"` +} + +type Future struct { + Symbol string `json:"symbol"` + ProductCode string `json:"product-code"` + ContractSize string `json:"contract-size"` + TickSize string `json:"tick-size"` + NotionalMultiplier string `json:"notional-multiplier"` + MainFraction string `json:"main-fraction"` + SubFraction string `json:"sub-fraction"` + DisplayFactor string `json:"display-factor"` + LastTradeDate string `json:"last-trade-date"` + ExpirationDate string `json:"expiration-date"` + ClosingOnlyDate string `json:"closing-only-date"` + Active bool `json:"active"` + ActiveMonth bool `json:"active-month"` + NextActiveMonth bool `json:"next-active-month"` + IsClosingOnly bool `json:"is-closing-only"` + StopsTradingAt string `json:"stops-trading-at"` + ExpiresAt string `json:"expires-at"` + ProductGroup string `json:"product-group"` + Exchange string `json:"exchange"` + RollTargetSymbol string `json:"roll-target-symbol"` + StreamerExchangeCode string `json:"streamer-exchange-code"` + StreamerSymbol string `json:"streamer-symbol"` + BackMonthFirstCalendarSymbol bool `json:"back-month-first-calendar-symbol"` + IsTradeable bool `json:"is-tradeable"` + FutureETFEquivalent FutureETFEquivalent `json:"future-etf-equivalent"` + FutureProduct FutureProduct `json:"future-product"` + TickSizes []TickSize `json:"tick-sizes"` + OptionTickSizes []TickSize `json:"option-tick-sizes"` + SpreadTickSizes []TickSize `json:"spread-tick-sizes"` +} + +type FuturesQueryResponse struct { + Data struct { + Items []Future `json:"items"` + } `json:"data"` +} + +type FuturesQueryParams struct { + Symbol []string `json:"symbol"` + ProductCode []string `json:"product-code"` +} + +type FutureResponse struct { + Data Future `json:"data"` + Context string `json:"context"` +} + +type FutureOptionProduct struct { + RootSymbol string `json:"root-symbol"` + CashSettled bool `json:"cash-settled"` + Code string `json:"code"` + LegacyCode string `json:"legacy-code"` + ClearportCode string `json:"clearport-code"` + ClearingCode string `json:"clearing-code"` + ClearingExchangeCode string `json:"clearing-exchange-code"` + ClearingPriceMultiplier string `json:"clearing-price-multiplier"` + DisplayFactor string `json:"display-factor"` + Exchange string `json:"exchange"` + ProductType string `json:"product-type"` + ExpirationType string `json:"expiration-type"` + SettlementDelayDays int `json:"settlement-delay-days"` + IsRollover bool `json:"is-rollover"` + MarketSector string `json:"market-sector"` +} + +type FutureProductsResponse struct { + Data struct { + Items []FutureProduct `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type FutureProductResponse struct { + Data FutureProduct `json:"data"` + Context string `json:"context"` +} + +type FuturesOptionExpirationNested struct { + UnderlyingSymbol string `json:"underlying-symbol"` + RootSymbol string `json:"root-symbol"` + OptionRootSymbol string `json:"option-root-symbol"` + OptionContractSymbol string `json:"option-contract-symbol"` + Asset string `json:"asset"` + ExpirationDate string `json:"expiration-date"` + DaysToExpiration int `json:"days-to-expiration"` + ExpirationType string `json:"expiration-type"` + SettlementType string `json:"settlement-type"` + NotionalValue string `json:"notional-value"` + DisplayFactor string `json:"display-factor"` + StrikeFactor string `json:"strike-factor"` + StopsTradingAt string `json:"stops-trading-at"` + ExpiresAt string `json:"expires-at"` + TickSizes []TickSize `json:"tick-sizes"` + Strikes []StrikeNested `json:"strikes"` +} + +type OptionChain struct { + UnderlyingSymbol string `json:"underlying-symbol"` + RootSymbol string `json:"root-symbol"` + ExerciseStyle string `json:"exercise-style"` + Expirations []FuturesOptionExpirationNested `json:"expirations"` +} + +type FutureOptionChainsNestedData struct { + Futures []Future `json:"futures"` + OptionChains []OptionChain `json:"option-chains"` +} + +type FutureOptionChainsNestedResponse struct { + Data FutureOptionChainsNestedData `json:"data"` + Context string `json:"context"` +} + +type FutureOption struct { + Symbol string `json:"symbol"` + UnderlyingSymbol string `json:"underlying-symbol"` + ProductCode string `json:"product-code"` + ExpirationDate string `json:"expiration-date"` + RootSymbol string `json:"root-symbol"` + OptionRootSymbol string `json:"option-root-symbol"` + StrikePrice string `json:"strike-price"` + Exchange string `json:"exchange"` + ExchangeSymbol string `json:"exchange-symbol"` + StreamerSymbol string `json:"streamer-symbol"` + OptionType string `json:"option-type"` + ExerciseStyle string `json:"exercise-style"` + IsVanilla bool `json:"is-vanilla"` + IsPrimaryDeliverable bool `json:"is-primary-deliverable"` + FuturePriceRatio string `json:"future-price-ratio"` + Multiplier string `json:"multiplier"` + UnderlyingCount string `json:"underlying-count"` + IsConfirmed bool `json:"is-confirmed"` + NotionalValue string `json:"notional-value"` + DisplayFactor string `json:"display-factor"` + SecurityExchange string `json:"security-exchange"` + SxID string `json:"sx-id"` + SettlementType string `json:"settlement-type"` + StrikeFactor string `json:"strike-factor"` + MaturityDate string `json:"maturity-date"` + IsExercisableWeekly bool `json:"is-exercisable-weekly"` + LastTradeTime string `json:"last-trade-time"` + DaysToExpiration int `json:"days-to-expiration"` + IsClosingOnly bool `json:"is-closing-only"` + Active bool `json:"active"` + StopsTradingAt string `json:"stops-trading-at"` + ExpiresAt string `json:"expires-at"` + FutureOptionProduct FutureOptionProduct `json:"future-option-product"` +} + +type FutureOptionChainsDetailedResponse struct { + Data struct { + Items []FutureOption `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type FutureOptionsDetailedResponse struct { + Data struct { + Items []FutureOption `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type FutureOptionProductsResponse struct { + Data struct { + Items []FutureOptionProduct `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type FutureOptionProductDetailedResponse struct { + Data FutureOptionProduct `json:"data"` + Context string `json:"context"` +} + +type FutureOptionsQueryParams struct { + Symbol []string `json:"symbol"` + OptionRootSymbol string `json:"option-root-symbol"` + ExpirationDate string `json:"expiration-date"` + OptionType string `json:"option-type"` + StrikePrice float64 `json:"strike-price"` +} + +type FutureOptionDetailedResponse struct { + Data FutureOption `json:"data"` + Context string `json:"context"` +} + +// QueryFutures retrieves a list of futures +func (api *TastytradeAPI) QueryFutures(params *FuturesQueryParams) (FuturesQueryResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/futures", api.host) + + if params != nil { + queryParams := url.Values{} + for _, symbol := range params.Symbol { + queryParams.Add("symbol[]", symbol) + } + for _, productCode := range params.ProductCode { + queryParams.Add("product-code[]", productCode) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return FuturesQueryResponse{}, err + } + + var response FuturesQueryResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FuturesQueryResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FuturesQueryResponse{}, err + } + + return response, nil +} + +// GetFuture retrieves data for a specific future symbol +func (api *TastytradeAPI) GetFuture(symbol string) (FutureResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/futures/%s", api.host, url.PathEscape(symbol)) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureResponse{}, err + } + + var response FutureResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureResponse{}, err + } + + return response, nil +} + +// ListFutureProducts retrieves a list of future products +func (api *TastytradeAPI) ListFutureProducts() (FutureProductsResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-products", api.host) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureProductsResponse{}, err + } + + var response FutureProductsResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureProductsResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureProductsResponse{}, err + } + + return response, nil +} + +// GetFutureProduct retrieves data for a specific future product +func (api *TastytradeAPI) GetFutureProduct(exchange string, symbol string) (FutureProductResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-products/%s/%s", api.host, exchange, url.PathEscape(symbol)) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureProductResponse{}, err + } + + var response FutureProductResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureProductResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureProductResponse{}, err + } + + return response, nil +} + +// ListFutureOptionChainsNested retrieves nested future option chain data for a specific symbol +func (api *TastytradeAPI) ListFutureOptionChainsNested(symbol string) (FutureOptionChainsNestedResponse, error) { + urlVal := fmt.Sprintf("%s/futures-option-chains/%s/nested", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionChainsNestedResponse{}, err + } + + var response FutureOptionChainsNestedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionChainsNestedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionChainsNestedResponse{}, err + } + + return response, nil +} + +// ListFutureOptionChainsDetailed retrieves detailed future option chain data for a specific symbol +func (api *TastytradeAPI) ListFutureOptionChainsDetailed(symbol string) (FutureOptionChainsDetailedResponse, error) { + urlVal := fmt.Sprintf("%s/futures-option-chains/%s", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionChainsDetailedResponse{}, err + } + + var response FutureOptionChainsDetailedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionChainsDetailedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionChainsDetailedResponse{}, err + } + + return response, nil +} + +// ListFutureOptions retrieves future option data for a specific symbol with query parameters +func (api *TastytradeAPI) ListFutureOptions(params *FutureOptionsQueryParams) (FutureOptionsDetailedResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-options", api.host) + + if params != nil { + queryParams := url.Values{} + for _, symbol := range params.Symbol { + queryParams.Add("symbol[]", symbol) + } + if params.OptionRootSymbol != "" { + queryParams.Add("option-root-symbol", params.OptionRootSymbol) + } + if params.ExpirationDate != "" { + queryParams.Add("expiration-date", params.ExpirationDate) + } + if params.OptionType != "" { + queryParams.Add("option-type", params.OptionType) + } + if params.StrikePrice != 0 { + queryParams.Add("strike-price", fmt.Sprintf("%.2f", params.StrikePrice)) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionsDetailedResponse{}, err + } + + var response FutureOptionsDetailedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionsDetailedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionsDetailedResponse{}, err + } + + return response, nil +} + +// GetFutureOption retrieves data for a specific future option symbol +func (api *TastytradeAPI) GetFutureOption(symbol string) (FutureOptionDetailedResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-options/%s", api.host, url.PathEscape(symbol)) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionDetailedResponse{}, err + } + + var response FutureOptionDetailedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionDetailedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionDetailedResponse{}, err + } + + return response, nil +} + +// ListFutureOptionProducts retrieves a list of future option products +func (api *TastytradeAPI) ListFutureOptionProducts() (FutureOptionProductsResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-option-products", api.host) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionProductsResponse{}, err + } + + var response FutureOptionProductsResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionProductsResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionProductsResponse{}, err + } + + return response, nil +} + +// GetFutureOptionProduct retrieves data for a specific future option product +func (api *TastytradeAPI) GetFutureOptionProduct(exchange string, rootSymbol string) (FutureOptionProductDetailedResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/future-option-products/%s/%s", api.host, exchange, rootSymbol) + data, err := api.fetchData(urlVal) + if err != nil { + return FutureOptionProductDetailedResponse{}, err + } + + var response FutureOptionProductDetailedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return FutureOptionProductDetailedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return FutureOptionProductDetailedResponse{}, err + } + + return response, nil +} diff --git a/futures_test.go b/futures_test.go new file mode 100644 index 0000000..eda8cf3 --- /dev/null +++ b/futures_test.go @@ -0,0 +1,304 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestQueryFutures(t *testing.T) { + expectedURL := "/instruments/futures" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"data": {"items": [{"symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.QueryFutures(nil) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } +} + +func TestQueryFuturesError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + _, err := api.QueryFutures(nil) + + if err == nil { + t.Errorf("expected an error, got nil") + } +} + +func TestQueryFuturesWithParams(t *testing.T) { + expectedURL := "/instruments/futures?product-code%5B%5D=123&symbol%5B%5D=AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"data": {"items": [{"symbol": "AAPL", "product-code": "123"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + params := &FuturesQueryParams{ + Symbol: []string{"AAPL"}, + ProductCode: []string{"123"}, + } + resp, err := api.QueryFutures(params) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } + + if resp.Data.Items[0].ProductCode != "123" { + t.Errorf("expected %s, got %s", "123", resp.Data.Items[0].ProductCode) + } +} + +func TestGetFuture(t *testing.T) { + expectedURL := "/instruments/futures/AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"data": {"symbol": "AAPL"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetFuture("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Data.Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Symbol) + } +} + +func TestGetFutureOptionProduct(t *testing.T) { + expectedURL := "/instruments/future-option-products/exchange/AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"root-symbol": "AAPL"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetFutureOptionProduct("exchange", "AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.RootSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.RootSymbol) + } +} + +func TestListFutureOptions(t *testing.T) { + expectedURL := "/instruments/future-options?symbol%5B%5D=AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL", "instrument-type": "future-option"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListFutureOptions(&FutureOptionsQueryParams{Symbol: []string{"AAPL"}}) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } +} + +func TestListFutureOptionChainsNested(t *testing.T) { + expectedURL := "/futures-option-chains/AAPL/nested" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"futures": [{"symbol": "AAPL"}], "option-chains": [{"underlying-symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListFutureOptionChainsNested("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Futures) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Futures)) + } + + if resp.Data.Futures[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Futures[0].Symbol) + } + + if len(resp.Data.OptionChains) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.OptionChains)) + } + + if resp.Data.OptionChains[0].UnderlyingSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.OptionChains[0].UnderlyingSymbol) + } +} + +func TestListFutureOptionChainsDetailed(t *testing.T) { + expectedURL := "/futures-option-chains/AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL", "underlying-symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListFutureOptionChainsDetailed("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } + + if resp.Data.Items[0].UnderlyingSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].UnderlyingSymbol) + } +} + +func TestGetFutureOption(t *testing.T) { + expectedURL := "/instruments/future-options/AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"symbol": "AAPL", "instrument-type": "future-option"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetFutureOption("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Symbol) + } +} + +func TestListFutureOptionProducts(t *testing.T) { + expectedURL := "/instruments/future-option-products" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.Write([]byte(`{"context": "test", "data": {"items": [{"root-symbol": "AAPL", "instrument-type": "future-option"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListFutureOptionProducts() + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].RootSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].RootSymbol) + } +} + +func TestGetFutureOptionProductError(t *testing.T) { + expectedURL := "/instruments/future-option-products/exchange/AAPL" + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.String() != expectedURL { + t.Errorf("expected URL to be %s, got %s", expectedURL, req.URL.String()) + } + rw.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + _, err := api.GetFutureOptionProduct("exchange", "AAPL") + + if err == nil { + t.Errorf("expected an error, got nil") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f56c23a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/optionsvamp/tastytrade + +go 1.22.3 diff --git a/option.go b/option.go new file mode 100644 index 0000000..46cc6a0 --- /dev/null +++ b/option.go @@ -0,0 +1,264 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type OptionDataDetailed struct { + HaltedAt string `json:"halted-at"` + InstrumentType string `json:"instrument-type"` + RootSymbol string `json:"root-symbol"` + Active bool `json:"active"` + IsClosingOnly bool `json:"is-closing-only"` + UnderlyingSymbol string `json:"underlying-symbol"` + DaysToExpiration int `json:"days-to-expiration"` + ExpirationDate string `json:"expiration-date"` + ExpiresAt string `json:"expires-at"` + ListedMarket string `json:"listed-market"` + StrikePrice string `json:"strike-price"` + OldSecurityNumber string `json:"old-security-number"` + OptionType string `json:"option-type"` + MarketTimeInstrumentCollection string `json:"market-time-instrument-collection"` + Symbol string `json:"symbol"` + StreamerSymbol string `json:"streamer-symbol"` + ExpirationType string `json:"expiration-type"` + SharesPerContract int `json:"shares-per-contract"` + StopsTradingAt string `json:"stops-trading-at"` + ExerciseStyle string `json:"exercise-style"` + SettlementType string `json:"settlement-type"` + OptionChainType string `json:"option-chain-type"` +} + +type OptionChainsDetailedResponse struct { + Context string `json:"context"` + Data struct { + Items []OptionDataDetailed `json:"items"` + } `json:"data"` +} + +type StrikeNested struct { + StrikePrice string `json:"strike-price"` + Call string `json:"call"` + CallStreamerSymbol string `json:"call-streamer-symbol"` + Put string `json:"put"` + PutStreamerSymbol string `json:"put-streamer-symbol"` +} + +type ExpirationNested struct { + ExpirationType string `json:"expiration-type"` + ExpirationDate string `json:"expiration-date"` + DaysToExpiration int `json:"days-to-expiration"` + SettlementType string `json:"settlement-type"` + Strikes []StrikeNested `json:"strikes"` +} + +type OptionChainItemNested struct { + UnderlyingSymbol string `json:"underlying-symbol"` + RootSymbol string `json:"root-symbol"` + OptionChainType string `json:"option-chain-type"` + SharesPerContract int `json:"shares-per-contract"` + Expirations []ExpirationNested `json:"expirations"` +} + +type OptionChainsNestedResponse struct { + Data struct { + Items []OptionChainItemNested `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type DeliverableCompact struct { + ID int `json:"id"` + RootSymbol string `json:"root-symbol"` + DeliverableType string `json:"deliverable-type"` + Description string `json:"description"` + Amount string `json:"amount"` + Symbol string `json:"symbol"` + InstrumentType string `json:"instrument-type"` + Percent string `json:"percent"` +} + +type OptionChainItemCompact struct { + UnderlyingSymbol string `json:"underlying-symbol"` + RootSymbol string `json:"root-symbol"` + OptionChainType string `json:"option-chain-type"` + SettlementType string `json:"settlement-type"` + SharesPerContract int `json:"shares-per-contract"` + ExpirationType string `json:"expiration-type"` + Deliverables []DeliverableCompact `json:"deliverables"` + Symbols []string `json:"symbols"` +} + +type OptionChainsCompactResponse struct { + Data struct { + Items []OptionChainItemCompact `json:"items"` + } `json:"data"` + Context string `json:"context"` +} + +type EquityOptionData struct { + Symbol string `json:"symbol"` + InstrumentType string `json:"instrument-type"` + Active bool `json:"active"` + StrikePrice string `json:"strike-price"` + RootSymbol string `json:"root-symbol"` + UnderlyingSymbol string `json:"underlying-symbol"` + ExpirationDate string `json:"expiration-date"` + ExerciseStyle string `json:"exercise-style"` + SharesPerContract int `json:"shares-per-contract"` + OptionType string `json:"option-type"` + OptionChainType string `json:"option-chain-type"` + ExpirationType string `json:"expiration-type"` + SettlementType string `json:"settlement-type"` + StopsTradingAt string `json:"stops-trading-at"` + MarketTimeInstrumentCollection string `json:"market-time-instrument-collection"` + DaysToExpiration int `json:"days-to-expiration"` + ExpiresAt string `json:"expires-at"` + IsClosingOnly bool `json:"is-closing-only"` + StreamerSymbol string `json:"streamer-symbol"` +} + +type EquityOptionsListResponse struct { + Context string `json:"context"` + Data struct { + Items []EquityOptionData `json:"items"` + } `json:"data"` +} + +type EquityOptionsQueryParams struct { + Symbol []string `json:"symbol"` + Active *bool `json:"active"` + WithExpired *bool `json:"with-expired"` +} + +type EquityOptionResponse struct { + Data EquityOptionData `json:"data"` + Context string `json:"context"` +} + +// ListOptionsChainsDetailed retrieves option chain data for a specific symbol +func (api *TastytradeAPI) ListOptionsChainsDetailed(symbol string) (OptionChainsDetailedResponse, error) { + urlVal := fmt.Sprintf("%s/option-chains/%s", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return OptionChainsDetailedResponse{}, err + } + + var response OptionChainsDetailedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return OptionChainsDetailedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return OptionChainsDetailedResponse{}, err + } + + return response, nil +} + +// ListOptionChainsNested retrieves nested option chain data for a specific symbol +func (api *TastytradeAPI) ListOptionChainsNested(symbol string) (OptionChainsNestedResponse, error) { + urlVal := fmt.Sprintf("%s/option-chains/%s/nested", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return OptionChainsNestedResponse{}, err + } + + var response OptionChainsNestedResponse + jsonData, err := json.Marshal(data) + if err != nil { + return OptionChainsNestedResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return OptionChainsNestedResponse{}, err + } + + return response, nil +} + +// GetOptionChainsCompact retrieves compact option chain data for a specific symbol +func (api *TastytradeAPI) GetOptionChainsCompact(symbol string) (OptionChainsCompactResponse, error) { + urlVal := fmt.Sprintf("%s/option-chains/%s/compact", api.host, symbol) + data, err := api.fetchData(urlVal) + if err != nil { + return OptionChainsCompactResponse{}, err + } + + var response OptionChainsCompactResponse + jsonData, err := json.Marshal(data) + if err != nil { + return OptionChainsCompactResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return OptionChainsCompactResponse{}, err + } + + return response, nil +} + +// GetEquityOptions retrieves a list of equity options +func (api *TastytradeAPI) GetEquityOptions(params *EquityOptionsQueryParams) (EquityOptionsListResponse, error) { + urlVal := fmt.Sprintf("%s/instruments/equity-options", api.host) + + if params != nil { + queryParams := url.Values{} + for _, symbol := range params.Symbol { + queryParams.Add("symbol[]", symbol) + } + if params.Active != nil { + queryParams.Add("active", fmt.Sprintf("%t", *params.Active)) + } + if params.WithExpired != nil { + queryParams.Add("with-expired", fmt.Sprintf("%t", *params.WithExpired)) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return EquityOptionsListResponse{}, err + } + + var response EquityOptionsListResponse + jsonData, err := json.Marshal(data) + if err != nil { + return EquityOptionsListResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return EquityOptionsListResponse{}, err + } + + return response, nil +} + +// GetEquityOption retrieves data for a specific equity option symbol +func (api *TastytradeAPI) GetEquityOption(symbol string) (EquityOptionResponse, error) { + url := fmt.Sprintf("%s/instruments/equity-options/%s", api.host, url.PathEscape(symbol)) + data, err := api.fetchData(url) + if err != nil { + return EquityOptionResponse{}, err + } + + var response EquityOptionResponse + jsonData, err := json.Marshal(data) + if err != nil { + return EquityOptionResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return EquityOptionResponse{}, err + } + + return response, nil +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..d57c650 --- /dev/null +++ b/option_test.go @@ -0,0 +1,137 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestListOptionsChainsDetailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL", "underlying-symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListOptionsChainsDetailed("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } + + if resp.Data.Items[0].UnderlyingSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].UnderlyingSymbol) + } +} + +func TestListOptionChainsNested(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"underlying-symbol": "AAPL", "root-symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.ListOptionChainsNested("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].UnderlyingSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].UnderlyingSymbol) + } +} + +func TestGetOptionChainsCompact(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"underlying-symbol": "AAPL", "root-symbol": "AAPL"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetOptionChainsCompact("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].UnderlyingSymbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].UnderlyingSymbol) + } +} + +func TestGetEquityOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL", "instrument-type": "equity-option"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetEquityOptions(&EquityOptionsQueryParams{Symbol: []string{"AAPL"}}) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } +} + +func TestGetEquityOption(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"symbol": "AAPL", "instrument-type": "equity-option"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetEquityOption("AAPL") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Symbol) + } +} diff --git a/positions.go b/positions.go new file mode 100644 index 0000000..2eb8af0 --- /dev/null +++ b/positions.go @@ -0,0 +1,61 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" +) + +type Position struct { + AccountNumber string `json:"account-number"` + Symbol string `json:"symbol"` + InstrumentType string `json:"instrument-type"` + UnderlyingSymbol string `json:"underlying-symbol"` + Quantity string `json:"quantity"` + QuantityDirection string `json:"quantity-direction"` + ClosePrice string `json:"close-price"` + AverageOpenPrice string `json:"average-open-price"` + AverageYearlyMarketClosePrice string `json:"average-yearly-market-close-price"` + AverageDailyMarketClosePrice string `json:"average-daily-market-close-price"` + Multiplier int `json:"multiplier"` + CostEffect string `json:"cost-effect"` + IsSuppressed bool `json:"is-suppressed"` + IsFrozen bool `json:"is-frozen"` + RestrictedQuantity string `json:"restricted-quantity"` + RealizedDayGain string `json:"realized-day-gain"` + RealizedDayGainEffect string `json:"realized-day-gain-effect"` + RealizedDayGainDate string `json:"realized-day-gain-date"` + RealizedToday string `json:"realized-today"` + RealizedTodayEffect string `json:"realized-today-effect"` + RealizedTodayDate string `json:"realized-today-date"` + CreatedAt string `json:"created-at"` + UpdatedAt string `json:"updated-at"` +} + +type PositionsResponse struct { + Context string `json:"context"` + Data struct { + Items []Position `json:"items"` + } `json:"data"` +} + +// GetPositions retrieves positions for a specific account +func (api *TastytradeAPI) GetPositions(accountNumber string) (PositionsResponse, error) { + url := fmt.Sprintf("%s/accounts/%s/positions", api.host, accountNumber) + data, err := api.fetchData(url) + if err != nil { + return PositionsResponse{}, err + } + + var response PositionsResponse + jsonData, err := json.Marshal(data) + if err != nil { + return PositionsResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return PositionsResponse{}, err + } + + return response, nil +} diff --git a/positions_test.go b/positions_test.go new file mode 100644 index 0000000..525e751 --- /dev/null +++ b/positions_test.go @@ -0,0 +1,37 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetPositions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"symbol": "AAPL", "quantity": "100"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetPositions("123456") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } + + if resp.Data.Items[0].Quantity != "100" { + t.Errorf("expected %s, got %s", "100", resp.Data.Items[0].Quantity) + } +} diff --git a/sessions.go b/sessions.go new file mode 100644 index 0000000..856b6d9 --- /dev/null +++ b/sessions.go @@ -0,0 +1,56 @@ +package tastytrade + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type User struct { + Email string `json:"email"` + Username string `json:"username"` + ExternalID string `json:"external-id"` + IsConfirmed bool `json:"is-confirmed"` +} + +type AuthData struct { + User User `json:"user"` + SessionToken string `json:"session-token"` +} + +type AuthResponse struct { + Data AuthData `json:"data"` + Context string `json:"context"` +} + +// Authenticate authenticates the client with the Tastytrade API +func (api *TastytradeAPI) Authenticate(username, password string) error { + authURL := fmt.Sprintf("%s/sessions", api.host) + authData := map[string]string{ + "login": username, + "password": password, + } + authBody, err := json.Marshal(authData) + if err != nil { + return err + } + + resp, err := api.httpClient.Post(authURL, "application/json", bytes.NewReader(authBody)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("authentication failed: status code %d", resp.StatusCode) + } + + authResponse := AuthResponse{} + if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { + return err + } + + api.authToken = authResponse.Data.SessionToken + return nil +} diff --git a/sessions_test.go b/sessions_test.go new file mode 100644 index 0000000..8e1b69a --- /dev/null +++ b/sessions_test.go @@ -0,0 +1,25 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAuthenticate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"user": {"email": "test@example.com", "username": "testuser", "external-id": "123", "is-confirmed": true}, "session-token": "testtoken"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + err := api.Authenticate("testuser", "testpassword") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if api.authToken != "testtoken" { + t.Errorf("expected %s, got %s", "testtoken", api.authToken) + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..2b97a96 --- /dev/null +++ b/status.go @@ -0,0 +1,67 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" +) + +type TradingStatusData struct { + AccountNumber string `json:"account-number"` + DayTradeCount int `json:"day-trade-count"` + EquitiesMarginCalculationType string `json:"equities-margin-calculation-type"` + FeeScheduleName string `json:"fee-schedule-name"` + FuturesMarginRateMultiplier string `json:"futures-margin-rate-multiplier"` + HasIntradayEquitiesMargin bool `json:"has-intraday-equities-margin"` + ID int `json:"id"` + IsAggregatedAtClearing bool `json:"is-aggregated-at-clearing"` + IsClosed bool `json:"is-closed"` + IsClosingOnly bool `json:"is-closing-only"` + IsCryptocurrencyClosingOnly bool `json:"is-cryptocurrency-closing-only"` + IsCryptocurrencyEnabled bool `json:"is-cryptocurrency-enabled"` + IsFrozen bool `json:"is-frozen"` + IsFullEquityMarginRequired bool `json:"is-full-equity-margin-required"` + IsFuturesClosingOnly bool `json:"is-futures-closing-only"` + IsFuturesIntraDayEnabled bool `json:"is-futures-intra-day-enabled"` + IsFuturesEnabled bool `json:"is-futures-enabled"` + IsInDayTradeEquityMaintenanceCall bool `json:"is-in-day-trade-equity-maintenance-call"` + IsInMarginCall bool `json:"is-in-margin-call"` + IsPatternDayTrader bool `json:"is-pattern-day-trader"` + IsRiskReducingOnly bool `json:"is-risk-reducing-only"` + IsSmallNotionalFuturesIntraDayEnabled bool `json:"is-small-notional-futures-intra-day-enabled"` + IsRollTheDayForwardEnabled bool `json:"is-roll-the-day-forward-enabled"` + AreFarOtmNetOptionsRestricted bool `json:"are-far-otm-net-options-restricted"` + OptionsLevel string `json:"options-level"` + ShortCallsEnabled bool `json:"short-calls-enabled"` + SmallNotionalFuturesMarginRateMultiplier string `json:"small-notional-futures-margin-rate-multiplier"` + IsEquityOfferingEnabled bool `json:"is-equity-offering-enabled"` + IsEquityOfferingClosingOnly bool `json:"is-equity-offering-closing-only"` + EnhancedFraudSafeguardsEnabledAt string `json:"enhanced-fraud-safeguards-enabled-at"` + UpdatedAt string `json:"updated-at"` +} + +type TradingStatusResponse struct { + Context string `json:"context"` + Data TradingStatusData `json:"data"` +} + +// GetAccountTradingStatus retrieves trading status for a specific account +func (api *TastytradeAPI) GetAccountTradingStatus(accountNumber string) (TradingStatusResponse, error) { + url := fmt.Sprintf("%s/accounts/%s/trading-status", api.host, accountNumber) + data, err := api.fetchData(url) + if err != nil { + return TradingStatusResponse{}, err + } + + var response TradingStatusResponse + jsonData, err := json.Marshal(data) + if err != nil { + return TradingStatusResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return TradingStatusResponse{}, err + } + + return response, nil +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..1b9febc --- /dev/null +++ b/status_test.go @@ -0,0 +1,33 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetAccountTradingStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"account-number": "123456", "day-trade-count": 1}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetAccountTradingStatus("123456") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.AccountNumber != "123456" { + t.Errorf("expected %s, got %s", "123456", resp.Data.AccountNumber) + } + + if resp.Data.DayTradeCount != 1 { + t.Errorf("expected %d, got %d", 1, resp.Data.DayTradeCount) + } +} diff --git a/transactions.go b/transactions.go new file mode 100644 index 0000000..e27e229 --- /dev/null +++ b/transactions.go @@ -0,0 +1,163 @@ +package tastytrade + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type Transaction struct { + ID int `json:"id"` + AccountNumber string `json:"account-number"` + Symbol string `json:"symbol"` + InstrumentType string `json:"instrument-type"` + UnderlyingSymbol string `json:"underlying-symbol"` + TransactionType string `json:"transaction-type"` + TransactionSubType string `json:"transaction-sub-type"` + Description string `json:"description"` + Action string `json:"action"` + Quantity string `json:"quantity"` + Price string `json:"price"` + ExecutedAt string `json:"executed-at"` + TransactionDate string `json:"transaction-date"` + Value string `json:"value"` + ValueEffect string `json:"value-effect"` + NetValue string `json:"net-value"` + NetValueEffect string `json:"net-value-effect"` + IsEstimatedFee bool `json:"is-estimated-fee"` +} + +type TransactionResponse struct { + Data Transaction `json:"data"` + Context string `json:"context"` +} + +type Pagination struct { + PerPage int `json:"per-page"` + PageOffset int `json:"page-offset"` + ItemOffset int `json:"item-offset"` + TotalItems int `json:"total-items"` + TotalPages int `json:"total-pages"` + CurrentItemCount int `json:"current-item-count"` + PreviousLink *string `json:"previous-link"` + NextLink *string `json:"next-link"` + PagingLinkTemplate *string `json:"paging-link-template"` +} + +type TransactionsResponse struct { + Data struct { + Items []Transaction `json:"items"` + } `json:"data"` + APIVersion string `json:"api-version"` + Context string `json:"context"` + Pagination Pagination `json:"pagination"` +} + +type TransactionQueryParams struct { + Sort string `json:"sort"` + Type string `json:"type"` + SubType []string `json:"sub-type"` + Types []string `json:"types"` + StartDate string `json:"start-date"` + EndDate string `json:"end-date"` + InstrumentType string `json:"instrument-type"` + Symbol string `json:"symbol"` + UnderlyingSymbol string `json:"underlying-symbol"` + Action string `json:"action"` + PartitionKey string `json:"partition-key"` + FuturesSymbol string `json:"futures-symbol"` + StartAt string `json:"start-at"` + EndAt string `json:"end-at"` +} + +// GetTransactions retrieves transactions for a specific account +func (api *TastytradeAPI) GetTransactions(accountNumber string, params *TransactionQueryParams) (TransactionsResponse, error) { + urlVal := fmt.Sprintf("%s/accounts/%s/transactions", api.host, accountNumber) + + if params != nil { + queryParams := url.Values{} + if params.Sort != "" { + queryParams.Add("sort", params.Sort) + } + if params.Type != "" { + queryParams.Add("type", params.Type) + } + for _, subType := range params.SubType { + queryParams.Add("sub-type", subType) + } + for _, types := range params.Types { + queryParams.Add("types", types) + } + if params.StartDate != "" { + queryParams.Add("start-date", params.StartDate) + } + if params.EndDate != "" { + queryParams.Add("end-date", params.EndDate) + } + if params.InstrumentType != "" { + queryParams.Add("instrument-type", params.InstrumentType) + } + if params.Symbol != "" { + queryParams.Add("symbol", params.Symbol) + } + if params.UnderlyingSymbol != "" { + queryParams.Add("underlying-symbol", params.UnderlyingSymbol) + } + if params.Action != "" { + queryParams.Add("action", params.Action) + } + if params.PartitionKey != "" { + queryParams.Add("partition-key", params.PartitionKey) + } + if params.FuturesSymbol != "" { + queryParams.Add("futures-symbol", params.FuturesSymbol) + } + if params.StartAt != "" { + queryParams.Add("start-at", params.StartAt) + } + if params.EndAt != "" { + queryParams.Add("end-at", params.EndAt) + } + urlVal = fmt.Sprintf("%s?%s", urlVal, queryParams.Encode()) + } + + data, err := api.fetchData(urlVal) + if err != nil { + return TransactionsResponse{}, err + } + + var response TransactionsResponse + jsonData, err := json.Marshal(data) + if err != nil { + return TransactionsResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return TransactionsResponse{}, err + } + + return response, nil +} + +// GetTransaction retrieves a specific transaction for a specific account +func (api *TastytradeAPI) GetTransaction(accountNumber string, transactionID string) (TransactionResponse, error) { + urlVal := fmt.Sprintf("%s/accounts/%s/transactions/%s", api.host, accountNumber, transactionID) + data, err := api.fetchData(urlVal) + if err != nil { + return TransactionResponse{}, err + } + + var response TransactionResponse + jsonData, err := json.Marshal(data) + if err != nil { + return TransactionResponse{}, err + } + + err = json.Unmarshal(jsonData, &response) + if err != nil { + return TransactionResponse{}, err + } + + return response, nil +} diff --git a/transactions_test.go b/transactions_test.go new file mode 100644 index 0000000..b251dcf --- /dev/null +++ b/transactions_test.go @@ -0,0 +1,117 @@ +package tastytrade + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetTransactions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"items": [{"id": 1, "account-number": "123456", "symbol": "AAPL", "transaction-type": "buy"}]}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetTransactions("123456", nil) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if len(resp.Data.Items) != 1 { + t.Errorf("expected %d, got %d", 1, len(resp.Data.Items)) + } + + if resp.Data.Items[0].ID != 1 { + t.Errorf("expected %d, got %d", 1, resp.Data.Items[0].ID) + } + + if resp.Data.Items[0].AccountNumber != "123456" { + t.Errorf("expected %s, got %s", "123456", resp.Data.Items[0].AccountNumber) + } + + if resp.Data.Items[0].Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Items[0].Symbol) + } + + if resp.Data.Items[0].TransactionType != "buy" { + t.Errorf("expected %s, got %s", "buy", resp.Data.Items[0].TransactionType) + } +} + +func TestGetTransaction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"id": 1, "account-number": "123", "symbol": "AAPL", "instrument-type": "equity-option"}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetTransaction("123", "1") + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Context != "test" { + t.Errorf("expected %s, got %s", "test", resp.Context) + } + + if resp.Data.Symbol != "AAPL" { + t.Errorf("expected %s, got %s", "AAPL", resp.Data.Symbol) + } +} + +func TestGetTransactionsPagination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"api-version": "1.0", "context": "test", "data": {"items": [{"id": 1, "account-number": "123", "symbol": "AAPL", "instrument-type": "equity-option"}]}, "pagination": {"per-page": 1, "total-items": 10}}`)) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + resp, err := api.GetTransactions("123", &TransactionQueryParams{Symbol: "AAPL"}) + + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if resp.Pagination.PerPage != 1 { + t.Errorf("expected %d, got %d", 1, resp.Pagination.PerPage) + } + + if resp.Pagination.TotalItems != 10 { + t.Errorf("expected %d, got %d", 10, resp.Pagination.TotalItems) + } +} + +func TestGetTransactionsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + _, err := api.GetTransactions("123", &TransactionQueryParams{Symbol: "AAPL"}) + + if err == nil { + t.Errorf("expected an error, got nil") + } +} + +func TestGetTransactionError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + api := NewTastytradeAPI(server.URL) + _, err := api.GetTransaction("123", "1") + + if err == nil { + t.Errorf("expected an error, got nil") + } +}