diff --git a/equity/client.go b/equity/client.go new file mode 100644 index 0000000..58e07b3 --- /dev/null +++ b/equity/client.go @@ -0,0 +1,29 @@ +package equity + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood equity MCP tools. +type Client struct { + c client.Caller +} + +// New returns an equity client that invokes tools through c. +func New(c client.Caller) *Client { + return &Client{c: c} +} + +// Tools returns the MCP names this package implements. +func Tools() []string { + return []string{ + toolPositions, + toolTaxLots, + toolQuotes, + toolOrders, + toolTradability, + toolHistoricals, + toolFundamentals, + toolPriceBook, + toolTechnicalIndicators, + toolNews, + } +} diff --git a/equity/read.go b/equity/read.go new file mode 100644 index 0000000..f9145c2 --- /dev/null +++ b/equity/read.go @@ -0,0 +1,571 @@ +package equity + +import ( + "context" + "encoding/json" + "time" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolPositions = "get_equity_positions" + toolTaxLots = "get_equity_tax_lots" + toolQuotes = "get_equity_quotes" + toolOrders = "get_equity_orders" + toolTradability = "get_equity_tradability" + toolHistoricals = "get_equity_historicals" + toolFundamentals = "get_equity_fundamentals" + toolPriceBook = "get_equity_price_book" + toolTechnicalIndicators = "get_equity_technical_indicators" + toolNews = "get_equity_news" +) + +// PositionsRequest is the argument set for get_equity_positions. +type PositionsRequest struct { + AccountNumber string + Cursor string +} + +// PositionsResult is the parsed get_equity_positions payload. +type PositionsResult struct{} + +// TaxLotsRequest is the argument set for get_equity_tax_lots. +type TaxLotsRequest struct { + AccountNumber string + Symbol string + Cursor string +} + +// TaxLotsResult is the parsed get_equity_tax_lots payload. +type TaxLotsResult struct{} + +// QuotesRequest is the argument set for get_equity_quotes. +type QuotesRequest struct { + Symbols []string +} + +// Quote is a top-of-book print from get_equity_quotes. +type Quote struct { + Symbol string + Bid decimal.Decimal + Ask decimal.Decimal + Last decimal.Decimal + PrevClose decimal.Decimal + Volume decimal.Decimal +} + +// QuotesResult is the parsed get_equity_quotes payload. +type QuotesResult struct { + Quotes []Quote +} + +// OrdersRequest is the argument set for get_equity_orders. +type OrdersRequest struct { + AccountNumber string + OrderID string + State string + Symbol string + CreatedAtGTE string + PlacedAgent string + Cursor string +} + +// OrdersResult is the parsed get_equity_orders payload. +type OrdersResult struct{} + +// TradabilityRequest is the argument set for get_equity_tradability. +type TradabilityRequest struct { + AccountNumber string + Symbols []string +} + +// TradabilityResult is the parsed get_equity_tradability payload. +type TradabilityResult struct{} + +// HistoricalsRequest is the argument set for get_equity_historicals. +type HistoricalsRequest struct { + Symbols []string + StartTime time.Time + EndTime time.Time + Interval string + Bounds string + AdjustmentType string +} + +// Bar is one equity historical candle from get_equity_historicals. +type Bar struct { + Symbol string + Time time.Time + Open, High, Low, Close decimal.Decimal + Volume decimal.Decimal + Interpolated bool +} + +// HistoricalsResult is the parsed get_equity_historicals payload. +type HistoricalsResult struct { + Bars []Bar +} + +// FundamentalsRequest is the argument set for get_equity_fundamentals. +type FundamentalsRequest struct { + Symbols []string + Bounds string +} + +// FundamentalsResult is the parsed get_equity_fundamentals payload. +type FundamentalsResult struct{} + +// PriceBookRequest is the argument set for get_equity_price_book. +type PriceBookRequest struct { + Symbols []string +} + +// PriceBookResult is the parsed get_equity_price_book payload. +type PriceBookResult struct{} + +// TechnicalIndicatorsRequest is the argument set for get_equity_technical_indicators. +type TechnicalIndicatorsRequest struct { + Symbol string + Type string + Interval string + StartTime time.Time + EndTime time.Time + Bounds string + AdjustmentType string + Output string + Period *int + NumStd *decimal.Decimal + FastPeriod *int + SlowPeriod *int + SignalPeriod *int + Multiplier *decimal.Decimal + Method string +} + +// TechnicalIndicatorsResult is the parsed get_equity_technical_indicators payload. +type TechnicalIndicatorsResult struct{} + +// NewsRequest is the argument set for get_equity_news. +type NewsRequest struct { + Symbol string + Limit int + Cursor string +} + +// NewsResult is the parsed get_equity_news payload. +type NewsResult struct{} + +// Positions calls get_equity_positions. +func (c *Client) Positions(ctx context.Context, req PositionsRequest) (PositionsResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out PositionsResult + if err := c.parse(ctx, toolPositions, args, &out); err != nil { + return PositionsResult{}, err + } + return out, nil +} + +// TaxLots calls get_equity_tax_lots. +func (c *Client) TaxLots(ctx context.Context, req TaxLotsRequest) (TaxLotsResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out TaxLotsResult + if err := c.parse(ctx, toolTaxLots, args, &out); err != nil { + return TaxLotsResult{}, err + } + return out, nil +} + +// Quotes calls get_equity_quotes. +func (c *Client) Quotes(ctx context.Context, req QuotesRequest) (QuotesResult, error) { + args := map[string]any{} + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + raw, err := c.c.Call(ctx, toolQuotes, args) + if err != nil { + return QuotesResult{}, err + } + quotes, err := parseQuotes(raw) + if err != nil { + return QuotesResult{}, client.ToolErrorf(toolQuotes, "parse: %w", err) + } + return QuotesResult{Quotes: quotes}, nil +} + +// Orders calls get_equity_orders. +func (c *Client) Orders(ctx context.Context, req OrdersRequest) (OrdersResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if req.OrderID != "" { + args["order_id"] = req.OrderID + } + if req.State != "" { + args["state"] = req.State + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.CreatedAtGTE != "" { + args["created_at_gte"] = req.CreatedAtGTE + } + if req.PlacedAgent != "" { + args["placed_agent"] = req.PlacedAgent + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out OrdersResult + if err := c.parse(ctx, toolOrders, args, &out); err != nil { + return OrdersResult{}, err + } + return out, nil +} + +// Tradability calls get_equity_tradability. +func (c *Client) Tradability(ctx context.Context, req TradabilityRequest) (TradabilityResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + var out TradabilityResult + if err := c.parse(ctx, toolTradability, args, &out); err != nil { + return TradabilityResult{}, err + } + return out, nil +} + +// Historicals calls get_equity_historicals. +func (c *Client) Historicals(ctx context.Context, req HistoricalsRequest) (HistoricalsResult, error) { + args := map[string]any{ + "start_time": req.StartTime.UTC().Format(time.RFC3339), + } + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + if !req.EndTime.IsZero() { + args["end_time"] = req.EndTime.UTC().Format(time.RFC3339) + } + if req.Interval != "" { + args["interval"] = req.Interval + } + if req.Bounds != "" { + args["bounds"] = req.Bounds + } + if req.AdjustmentType != "" { + args["adjustment_type"] = req.AdjustmentType + } + raw, err := c.c.Call(ctx, toolHistoricals, args) + if err != nil { + return HistoricalsResult{}, err + } + bars, err := parseHistoricals(raw) + if err != nil { + return HistoricalsResult{}, client.ToolErrorf(toolHistoricals, "parse: %w", err) + } + return HistoricalsResult{Bars: bars}, nil +} + +// Fundamentals calls get_equity_fundamentals. +func (c *Client) Fundamentals(ctx context.Context, req FundamentalsRequest) (FundamentalsResult, error) { + args := map[string]any{} + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + if req.Bounds != "" { + args["bounds"] = req.Bounds + } + var out FundamentalsResult + if err := c.parse(ctx, toolFundamentals, args, &out); err != nil { + return FundamentalsResult{}, err + } + return out, nil +} + +// PriceBook calls get_equity_price_book. +func (c *Client) PriceBook(ctx context.Context, req PriceBookRequest) (PriceBookResult, error) { + args := map[string]any{} + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + var out PriceBookResult + if err := c.parse(ctx, toolPriceBook, args, &out); err != nil { + return PriceBookResult{}, err + } + return out, nil +} + +// TechnicalIndicators calls get_equity_technical_indicators. +func (c *Client) TechnicalIndicators(ctx context.Context, req TechnicalIndicatorsRequest) (TechnicalIndicatorsResult, error) { + args := map[string]any{ + "start_time": req.StartTime.UTC().Format(time.RFC3339), + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.Type != "" { + args["type"] = req.Type + } + if req.Interval != "" { + args["interval"] = req.Interval + } + if !req.EndTime.IsZero() { + args["end_time"] = req.EndTime.UTC().Format(time.RFC3339) + } + if req.Bounds != "" { + args["bounds"] = req.Bounds + } + if req.AdjustmentType != "" { + args["adjustment_type"] = req.AdjustmentType + } + if req.Output != "" { + args["output"] = req.Output + } + if req.Period != nil { + args["period"] = *req.Period + } + if req.NumStd != nil { + args["num_std"] = wire.Encode(*req.NumStd) + } + if req.FastPeriod != nil { + args["fast_period"] = *req.FastPeriod + } + if req.SlowPeriod != nil { + args["slow_period"] = *req.SlowPeriod + } + if req.SignalPeriod != nil { + args["signal_period"] = *req.SignalPeriod + } + if req.Multiplier != nil { + args["multiplier"] = wire.Encode(*req.Multiplier) + } + if req.Method != "" { + args["method"] = req.Method + } + var out TechnicalIndicatorsResult + if err := c.parse(ctx, toolTechnicalIndicators, args, &out); err != nil { + return TechnicalIndicatorsResult{}, err + } + return out, nil +} + +// News calls get_equity_news. +func (c *Client) News(ctx context.Context, req NewsRequest) (NewsResult, error) { + args := map[string]any{} + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.Limit != 0 { + args["limit"] = req.Limit + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out NewsResult + if err := c.parse(ctx, toolNews, args, &out); err != nil { + return NewsResult{}, err + } + return out, nil +} + +func (c *Client) parse(ctx context.Context, tool string, args map[string]any, dest any) error { + raw, err := c.c.Call(ctx, tool, args) + if err != nil { + return err + } + if err := json.Unmarshal(wire.Unwrap(raw), dest); err != nil { + return client.ToolErrorf(tool, "parse: %w", err) + } + return nil +} + +type quoteFields struct { + Symbol string `json:"symbol"` + Last any `json:"last"` + LastTradePrice any `json:"last_trade_price"` + LastNonRegTradePrice any `json:"last_non_reg_trade_price"` + Bid any `json:"bid"` + BidPrice any `json:"bid_price"` + Ask any `json:"ask"` + AskPrice any `json:"ask_price"` + PreviousClose any `json:"previous_close"` + AdjustedPreviousClose any `json:"adjusted_previous_close"` + Volume any `json:"volume"` +} + +type quoteRow struct { + Quote *quoteFields `json:"quote"` + Close *struct { + Symbol string `json:"symbol"` + Price any `json:"price"` + } `json:"close"` + quoteFields +} + +func (r quoteRow) asQuote() (Quote, bool, error) { + f := r.quoteFields + if r.Quote != nil { + f = *r.Quote + } + if f.Symbol == "" && r.Close != nil { + f.Symbol = r.Close.Symbol + } + if f.Symbol == "" { + return Quote{}, false, nil + } + var closePx any + if r.Close != nil { + closePx = r.Close.Price + } + last, err := firstDec(f.LastTradePrice, f.Last, f.LastNonRegTradePrice) + if err != nil { + return Quote{}, false, err + } + prev, err := firstDec(closePx, f.PreviousClose, f.AdjustedPreviousClose) + if err != nil { + return Quote{}, false, err + } + bid, err := firstDec(f.BidPrice, f.Bid) + if err != nil { + return Quote{}, false, err + } + ask, err := firstDec(f.AskPrice, f.Ask) + if err != nil { + return Quote{}, false, err + } + vol, err := firstDec(f.Volume) + if err != nil { + return Quote{}, false, err + } + return Quote{ + Symbol: f.Symbol, + Last: last, + PrevClose: prev, + Bid: bid, + Ask: ask, + Volume: vol, + }, true, nil +} + +func parseQuotes(raw json.RawMessage) ([]Quote, error) { + var wrap struct { + Quotes []quoteRow `json:"quotes"` + } + if err := json.Unmarshal(wire.Unwrap(raw), &wrap); err != nil { + return nil, err + } + out := make([]Quote, 0, len(wrap.Quotes)) + for _, row := range wrap.Quotes { + q, ok, err := row.asQuote() + if err != nil { + return nil, err + } + if !ok { + continue + } + out = append(out, q) + } + return out, nil +} + +type histPointJSON struct { + BeginsAt string `json:"begins_at"` + Open any `json:"open"` + High any `json:"high"` + Low any `json:"low"` + Close any `json:"close"` + Volume any `json:"volume"` + Interpolated bool `json:"interpolated"` +} + +type histSeriesJSON struct { + Symbol string `json:"symbol"` + DataPoints []histPointJSON `json:"data_points"` +} + +func parseHistoricals(raw json.RawMessage) ([]Bar, error) { + var wrap struct { + Historicals []histSeriesJSON `json:"historicals"` + } + if err := json.Unmarshal(wire.Unwrap(raw), &wrap); err != nil { + return nil, err + } + var out []Bar + for _, series := range wrap.Historicals { + if series.Symbol == "" { + continue + } + for _, p := range series.DataPoints { + ts, err := time.Parse(time.RFC3339, p.BeginsAt) + if err != nil { + continue + } + o, err := firstDec(p.Open) + if err != nil { + return nil, err + } + h, err := firstDec(p.High) + if err != nil { + return nil, err + } + l, err := firstDec(p.Low) + if err != nil { + return nil, err + } + cl, err := firstDec(p.Close) + if err != nil { + return nil, err + } + vol, err := firstDec(p.Volume) + if err != nil { + return nil, err + } + out = append(out, Bar{ + Symbol: series.Symbol, + Time: ts, + Open: o, + High: h, + Low: l, + Close: cl, + Volume: vol, + Interpolated: p.Interpolated, + }) + } + } + return out, nil +} + +func firstDec(vs ...any) (decimal.Decimal, error) { + for _, v := range vs { + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue + } + return wire.Dec(v) + } + return decimal.Zero, nil +} diff --git a/equity/read_test.go b/equity/read_test.go new file mode 100644 index 0000000..7245987 --- /dev/null +++ b/equity/read_test.go @@ -0,0 +1,304 @@ +package equity_test + +import ( + "context" + "encoding/json" + "sort" + "testing" + "time" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/equity" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" +) + +func TestEquity_toolNames(t *testing.T) { + t.Parallel() + start := time.Date(2026, 8, 18, 13, 30, 0, 0, time.UTC) + end := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC) + period := 14 + fast := 12 + slow := 26 + signal := 9 + numStd := decimal.RequireFromString("2") + mult := decimal.RequireFromString("3") + tests := []struct { + name string + call func(*equity.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "Positions", + call: func(c *equity.Client) error { + _, err := c.Positions(context.Background(), equity.PositionsRequest{ + AccountNumber: "acct-1", + Cursor: "c1", + }) + return err + }, + wantName: "get_equity_positions", + wantArgs: map[string]any{"account_number": "acct-1", "cursor": "c1"}, + }, + { + name: "TaxLots", + call: func(c *equity.Client) error { + _, err := c.TaxLots(context.Background(), equity.TaxLotsRequest{ + AccountNumber: "acct-1", + Symbol: "MU", + Cursor: "c1", + }) + return err + }, + wantName: "get_equity_tax_lots", + wantArgs: map[string]any{"account_number": "acct-1", "symbol": "MU", "cursor": "c1"}, + }, + { + name: "Quotes", + call: func(c *equity.Client) error { + _, err := c.Quotes(context.Background(), equity.QuotesRequest{Symbols: []string{"MU"}}) + return err + }, + wantName: "get_equity_quotes", + wantArgs: map[string]any{"symbols": []string{"MU"}}, + }, + { + name: "Orders", + call: func(c *equity.Client) error { + _, err := c.Orders(context.Background(), equity.OrdersRequest{ + AccountNumber: "acct-1", + OrderID: "o1", + State: "filled", + Symbol: "MU", + CreatedAtGTE: "2026-08-18", + PlacedAgent: "agentic", + Cursor: "c1", + }) + return err + }, + wantName: "get_equity_orders", + wantArgs: map[string]any{ + "account_number": "acct-1", + "order_id": "o1", + "state": "filled", + "symbol": "MU", + "created_at_gte": "2026-08-18", + "placed_agent": "agentic", + "cursor": "c1", + }, + }, + { + name: "Tradability", + call: func(c *equity.Client) error { + _, err := c.Tradability(context.Background(), equity.TradabilityRequest{ + AccountNumber: "acct-1", + Symbols: []string{"MU"}, + }) + return err + }, + wantName: "get_equity_tradability", + wantArgs: map[string]any{"account_number": "acct-1", "symbols": []string{"MU"}}, + }, + { + name: "Historicals", + call: func(c *equity.Client) error { + _, err := c.Historicals(context.Background(), equity.HistoricalsRequest{ + Symbols: []string{"MU"}, + StartTime: start, + }) + return err + }, + wantName: "get_equity_historicals", + wantArgs: map[string]any{ + "symbols": []string{"MU"}, + "start_time": "2026-08-18T13:30:00Z", + }, + }, + { + name: "Fundamentals", + call: func(c *equity.Client) error { + _, err := c.Fundamentals(context.Background(), equity.FundamentalsRequest{ + Symbols: []string{"MU"}, + Bounds: "regular", + }) + return err + }, + wantName: "get_equity_fundamentals", + wantArgs: map[string]any{"symbols": []string{"MU"}, "bounds": "regular"}, + }, + { + name: "PriceBook", + call: func(c *equity.Client) error { + _, err := c.PriceBook(context.Background(), equity.PriceBookRequest{Symbols: []string{"MU"}}) + return err + }, + wantName: "get_equity_price_book", + wantArgs: map[string]any{"symbols": []string{"MU"}}, + }, + { + name: "TechnicalIndicators", + call: func(c *equity.Client) error { + _, err := c.TechnicalIndicators(context.Background(), equity.TechnicalIndicatorsRequest{ + Symbol: "MU", + Type: "macd", + Interval: "minute", + StartTime: start, + EndTime: end, + Bounds: "regular", + AdjustmentType: "split", + Output: "latest", + Period: &period, + NumStd: &numStd, + FastPeriod: &fast, + SlowPeriod: &slow, + SignalPeriod: &signal, + Multiplier: &mult, + Method: "classic", + }) + return err + }, + wantName: "get_equity_technical_indicators", + wantArgs: map[string]any{ + "symbol": "MU", + "type": "macd", + "interval": "minute", + "start_time": "2026-08-18T13:30:00Z", + "end_time": "2026-08-18T20:00:00Z", + "bounds": "regular", + "adjustment_type": "split", + "output": "latest", + "period": 14, + "num_std": "2", + "fast_period": 12, + "slow_period": 26, + "signal_period": 9, + "multiplier": "3", + "method": "classic", + }, + }, + { + name: "News", + call: func(c *equity.Client) error { + _, err := c.News(context.Background(), equity.NewsRequest{ + Symbol: "MU", + Limit: 5, + Cursor: "c1", + }) + return err + }, + wantName: "get_equity_news", + wantArgs: map[string]any{"symbol": "MU", "limit": 5, "cursor": "c1"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + gotName, gotArgs = name, args + return json.RawMessage(`{}`), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if gotName != tc.wantName { + t.Fatalf("%s %+v", gotName, gotArgs) + } + if diff := cmp.Diff(tc.wantArgs, gotArgs); diff != "" { + t.Fatal(diff) + } + if tc.name == "Historicals" { + if _, ok := gotArgs["interval"]; ok { + t.Fatalf("interval injected: %+v", gotArgs) + } + if _, ok := gotArgs["bounds"]; ok { + t.Fatalf("bounds injected: %+v", gotArgs) + } + } + }) + } +} + +func TestTools(t *testing.T) { + t.Parallel() + want := []string{ + "get_equity_fundamentals", + "get_equity_historicals", + "get_equity_news", + "get_equity_orders", + "get_equity_positions", + "get_equity_price_book", + "get_equity_quotes", + "get_equity_tax_lots", + "get_equity_technical_indicators", + "get_equity_tradability", + } + got := append([]string(nil), equity.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestQuotes_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_equity_quotes", json.RawMessage(`{"quotes":[{"symbol":"MU","quote":{"symbol":"MU","last_trade_price":"100","bid_price":"99.9","ask_price":"100.1"},"close":{"symbol":"MU","price":"98"}}]}`)) + c := equity.New(&client.Client{URL: s.URL}) + got, err := c.Quotes(context.Background(), equity.QuotesRequest{Symbols: []string{"MU"}}) + if err != nil { + t.Fatal(err) + } + if len(got.Quotes) != 1 { + t.Fatalf("%+v", got) + } + q := got.Quotes[0] + if q.Symbol != "MU" { + t.Fatalf("%+v", q) + } + if !q.Last.Equal(decimal.RequireFromString("100")) { + t.Fatalf("last %s", q.Last) + } + if !q.Bid.Equal(decimal.RequireFromString("99.9")) { + t.Fatalf("bid %s", q.Bid) + } + if !q.PrevClose.Equal(decimal.RequireFromString("98")) { + t.Fatalf("prev close %s", q.PrevClose) + } +} + +func TestHistoricals_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_equity_historicals", json.RawMessage(`{"historicals":[{"symbol":"MU","data_points":[ + {"begins_at":"2026-08-18T13:30:00Z","open":"10","high":"11","low":"9","close":"10","volume":"100","interpolated":false}, + {"begins_at":"2026-08-18T13:31:00Z","open":"10","high":"10","low":"10","close":"10","volume":"1","interpolated":true} + ]}]}`)) + c := equity.New(&client.Client{URL: s.URL}) + got, err := c.Historicals(context.Background(), equity.HistoricalsRequest{ + Symbols: []string{"MU"}, + StartTime: time.Date(2026, 8, 18, 13, 30, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if len(got.Bars) != 2 { + t.Fatalf("bars %d", len(got.Bars)) + } + b0 := got.Bars[0] + if b0.Symbol != "MU" || b0.Interpolated { + t.Fatalf("%+v", b0) + } + if !b0.Time.Equal(time.Date(2026, 8, 18, 13, 30, 0, 0, time.UTC)) { + t.Fatalf("time %s", b0.Time) + } + if !b0.Open.Equal(decimal.RequireFromString("10")) || !b0.High.Equal(decimal.RequireFromString("11")) || !b0.Low.Equal(decimal.RequireFromString("9")) || !b0.Close.Equal(decimal.RequireFromString("10")) || !b0.Volume.Equal(decimal.RequireFromString("100")) { + t.Fatalf("%+v", b0) + } + if !got.Bars[1].Interpolated { + t.Fatalf("%+v", got.Bars[1]) + } +}