From d696841e1406c5779fff18a29a0db4909b4e0c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 20 May 2026 14:12:34 +0800 Subject: [PATCH 1/7] feat(go): add shareholders, screener, short-trades-rank, and market-rank APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fundamental: ShareholderTop, ShareholderDetail, ValuationComparison (3 new methods) - quote: HkShortPositions, ShortTrades with auto market detection (2 new methods) - market: StockEvents (POST), RankCategories, RankList (3 new methods) - screener: new package with ScreenerContext — RecommendStrategies, UserStrategies, Strategy, Search, Indicators (5 new methods) All responses use json.RawMessage for forward-compatible raw data access. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CHANGELOG.md | 35 ++++++-- fundamental/context.go | 73 ++++++++++++++++ fundamental/jsontypes/types.go | 75 +++++++++++------ fundamental/types.go | 24 ++++++ market/context.go | 64 ++++++++++++++ market/types.go | 19 +++++ quote/context.go | 42 ++++++++++ quote/types.go | 13 +++ screener/context.go | 147 +++++++++++++++++++++++++++++++++ screener/types.go | 36 ++++++++ 10 files changed, 494 insertions(+), 34 deletions(-) create mode 100644 screener/context.go create mode 100644 screener/types.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 033f331..1629ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,34 @@ ### Added -- **Go:** Six new `FundamentalContext` methods: - - `BusinessSegments` — GET `/v1/quote/fundamentals/business-segments`: latest business segment breakdown. - - `BusinessSegmentsHistory` — GET `/v1/quote/fundamentals/business-segments/history`: historical business and regional segment breakdowns with optional `report` and `cate` filters. - - `InstitutionRatingViews` — GET `/v1/quote/ratings/institutional`: historical rating distribution time-series (buy/over/hold/under/sell per date). - - `IndustryRank` — GET `/v1/quote/industry/rank`: industry leaderboard; exposes `IndustryRankIndicator` and `IndustryRankSortType` enum constants. - - `IndustryPeers` — GET `/v1/quote/industries/peers`: recursive industry peer chain; accepts both symbol-style (`AAPL.US`) and raw counter IDs (`BK/US/123`). - - `FinancialReportSnapshot` — GET `/v1/quote/financials/earnings-snapshot`: earnings snapshot with forecast and reported metrics. +- **Go:** Six new `FundamentalContext` methods (merged from PR #91): + - `BusinessSegments` — GET `/v1/quote/fundamentals/business-segments` + - `BusinessSegmentsHistory` — GET `/v1/quote/fundamentals/business-segments/history` + - `InstitutionRatingViews` — GET `/v1/quote/ratings/institutional` + - `IndustryRank` — GET `/v1/quote/industry/rank` + - `IndustryPeers` — GET `/v1/quote/industries/peers` + - `FinancialReportSnapshot` — GET `/v1/quote/financials/earnings-snapshot` + +- **Go:** New `screener` package with `ScreenerContext` (5 methods): + - `ScreenerRecommendStrategies` — GET `/v1/quote/screener/strategies/recommend` + - `ScreenerUserStrategies` — GET `/v1/quote/screener/strategies/mine` + - `ScreenerStrategy` — GET `/v1/quote/screener/strategy` + - `ScreenerSearch` — POST `/v1/quote/screener/search` + - `ScreenerIndicators` — GET `/v1/quote/screener/indicators` + +- **Go:** Three new `FundamentalContext` methods: + - `ShareholderTop` — GET `/v1/quote/shareholders/top` + - `ShareholderDetail` — GET `/v1/quote/shareholders/holding` + - `ValuationComparison` — GET `/v1/quote/compare/valuation` + +- **Go:** Two new `QuoteContext` methods: + - `ShortPositions(ctx, symbol, count)` — GET `/v1/quote/short-positions/hk` or `/us` (auto-detected from symbol suffix) + - `ShortTrades` — GET `/v1/quote/short-trades/hk` or `/us` (auto-detected) + +- **Go:** Three new `MarketContext` methods: + - `TopMovers` — POST `/v1/quote/market/stock-events` + - `RankCategories` — GET `/v1/quote/market/rank/categories` + - `RankList` — GET `/v1/quote/market/rank/list` ## [v4.1.0] - 2026-05-14 diff --git a/fundamental/context.go b/fundamental/context.go index 6da63f9..8c7a704 100644 --- a/fundamental/context.go +++ b/fundamental/context.go @@ -1109,6 +1109,79 @@ func convertStockRatings(j *jsontypes.StockRatings) *StockRatings { } } +// ─── ShareholderTop ─────────────────────────────────────────────────────────── + +// ShareholderTop fetches the top shareholders list for a security. +// +// Path: GET /v1/quote/shareholders/top +func (c *FundamentalContext) ShareholderTop( + ctx context.Context, + symbol string, +) (*ShareholderTopResponse, error) { + q := url.Values{} + q.Set("counter_id", symbolToCounterID(symbol)) + var resp jsontypes.ShareholderTopResponse + if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/top", q, &resp); err != nil { + return nil, err + } + return &ShareholderTopResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ShareholderDetail ──────────────────────────────────────────────────────── + +// ShareholderDetail fetches the holding detail for a specific shareholder. +// +// Path: GET /v1/quote/shareholders/holding +func (c *FundamentalContext) ShareholderDetail( + ctx context.Context, + symbol string, + objectID int64, +) (*ShareholderDetailResponse, error) { + q := url.Values{} + q.Set("counter_id", symbolToCounterID(symbol)) + q.Set("object_id", strconv.FormatInt(objectID, 10)) + var resp jsontypes.ShareholderDetailResponse + if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/holding", q, &resp); err != nil { + return nil, err + } + return &ShareholderDetailResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ValuationComparison ────────────────────────────────────────────────────── + +// ValuationComparison fetches valuation comparison data for a symbol against +// a set of peer symbols. +// +// Path: GET /v1/quote/compare/valuation +// +// comparisonSymbols is a list of peer symbols (e.g. ["MSFT.US", "GOOG.US"]) +// that are converted to counter_ids and serialized as a JSON array string in +// the comparison_counter_ids query parameter. +func (c *FundamentalContext) ValuationComparison( + ctx context.Context, + symbol string, + currency string, + comparisonSymbols []string, +) (*ValuationComparisonResponse, error) { + counterIDs := make([]string, 0, len(comparisonSymbols)) + for _, s := range comparisonSymbols { + counterIDs = append(counterIDs, symbolToCounterID(s)) + } + counterIDsJSON, err := json.Marshal(counterIDs) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("counter_id", symbolToCounterID(symbol)) + q.Set("currency", currency) + q.Set("comparison_counter_ids", string(counterIDsJSON)) + var resp jsontypes.ValuationComparisonResponse + if err := c.httpClient.Get(ctx, "/v1/quote/compare/valuation", q, &resp); err != nil { + return nil, err + } + return &ValuationComparisonResponse{Data: json.RawMessage(resp.Data)}, nil +} + // parseTimestampNumber converts a json.Number (int or quoted string) to int64 Unix seconds. func parseTimestampNumber(n json.Number) int64 { s := n.String() diff --git a/fundamental/jsontypes/types.go b/fundamental/jsontypes/types.go index b0fc6eb..1c21366 100644 --- a/fundamental/jsontypes/types.go +++ b/fundamental/jsontypes/types.go @@ -612,11 +612,11 @@ type BusinessSegmentsHistory struct { // BusinessSegmentsHistoricalItem is one historical business segments snapshot. type BusinessSegmentsHistoricalItem struct { - Date string `json:"date"` - Total string `json:"total"` - Currency string `json:"currency"` - Business []BusinessSegmentHistoryItem `json:"business"` - Regionals []BusinessSegmentHistoryItem `json:"regionals"` + Date string `json:"date"` + Total string `json:"total"` + Currency string `json:"currency"` + Business []BusinessSegmentHistoryItem `json:"business"` + Regionals []BusinessSegmentHistoryItem `json:"regionals"` } // BusinessSegmentHistoryItem is one business/regional segment entry in a @@ -700,28 +700,28 @@ type IndustryPeerNode struct { // FinancialReportSnapshot is the raw response for // GET /v1/quote/financials/earnings-snapshot. type FinancialReportSnapshot struct { - Name string `json:"name"` - Ticker string `json:"ticker"` - FpStart string `json:"fp_start"` - FpEnd string `json:"fp_end"` - Currency string `json:"currency"` - ReportDesc string `json:"report_desc"` - FoRevenue *SnapshotForecastMetric `json:"fo_revenue"` - FoEbit *SnapshotForecastMetric `json:"fo_ebit"` - FoEps *SnapshotForecastMetric `json:"fo_eps"` - FrRevenue *SnapshotReportedMetric `json:"fr_revenue"` - FrProfit *SnapshotReportedMetric `json:"fr_profit"` - FrOperateCash *SnapshotReportedMetric `json:"fr_operate_cash"` - FrInvestCash *SnapshotReportedMetric `json:"fr_invest_cash"` - FrFinanceCash *SnapshotReportedMetric `json:"fr_finance_cash"` - FrTotalAssets *SnapshotReportedMetric `json:"fr_total_assets"` - FrTotalLiability *SnapshotReportedMetric `json:"fr_total_liability"` - FrRoeTtm string `json:"fr_roe_ttm"` - FrProfitMargin string `json:"fr_profit_margin"` - FrProfitMarginTtm string `json:"fr_profit_margin_ttm"` - FrAssetTurnTtm string `json:"fr_asset_turn_ttm"` - FrLeverageTtm string `json:"fr_leverage_ttm"` - FrDebtAssetsRatio string `json:"fr_debt_assets_ratio"` + Name string `json:"name"` + Ticker string `json:"ticker"` + FpStart string `json:"fp_start"` + FpEnd string `json:"fp_end"` + Currency string `json:"currency"` + ReportDesc string `json:"report_desc"` + FoRevenue *SnapshotForecastMetric `json:"fo_revenue"` + FoEbit *SnapshotForecastMetric `json:"fo_ebit"` + FoEps *SnapshotForecastMetric `json:"fo_eps"` + FrRevenue *SnapshotReportedMetric `json:"fr_revenue"` + FrProfit *SnapshotReportedMetric `json:"fr_profit"` + FrOperateCash *SnapshotReportedMetric `json:"fr_operate_cash"` + FrInvestCash *SnapshotReportedMetric `json:"fr_invest_cash"` + FrFinanceCash *SnapshotReportedMetric `json:"fr_finance_cash"` + FrTotalAssets *SnapshotReportedMetric `json:"fr_total_assets"` + FrTotalLiability *SnapshotReportedMetric `json:"fr_total_liability"` + FrRoeTtm string `json:"fr_roe_ttm"` + FrProfitMargin string `json:"fr_profit_margin"` + FrProfitMarginTtm string `json:"fr_profit_margin_ttm"` + FrAssetTurnTtm string `json:"fr_asset_turn_ttm"` + FrLeverageTtm string `json:"fr_leverage_ttm"` + FrDebtAssetsRatio string `json:"fr_debt_assets_ratio"` } // SnapshotForecastMetric is a forecast metric in the financial report snapshot. @@ -737,3 +737,24 @@ type SnapshotReportedMetric struct { Value string `json:"value"` Yoy string `json:"yoy"` } + +// ── shareholder_top ────────────────────────────────────────────── + +// ShareholderTopResponse is the raw response for GET /v1/quote/shareholders/top. +type ShareholderTopResponse struct { + Data json.RawMessage `json:"data"` +} + +// ── shareholder_detail ─────────────────────────────────────────── + +// ShareholderDetailResponse is the raw response for GET /v1/quote/shareholders/holding. +type ShareholderDetailResponse struct { + Data json.RawMessage `json:"data"` +} + +// ── valuation_comparison ───────────────────────────────────────── + +// ValuationComparisonResponse is the raw response for GET /v1/quote/compare/valuation. +type ValuationComparisonResponse struct { + Data json.RawMessage `json:"data"` +} diff --git a/fundamental/types.go b/fundamental/types.go index 2ab7ae7..c2ae75f 100644 --- a/fundamental/types.go +++ b/fundamental/types.go @@ -1231,3 +1231,27 @@ type SnapshotReportedMetric struct { // Year-over-year change. Yoy string } + +// ── ShareholderTopResponse ──────────────────────────────────────── + +// ShareholderTopResponse holds the raw data for the top shareholders list. +// The Data field contains the JSON payload from GET /v1/quote/shareholders/top. +type ShareholderTopResponse struct { + Data json.RawMessage +} + +// ── ShareholderDetailResponse ───────────────────────────────────── + +// ShareholderDetailResponse holds the raw data for a single shareholder's +// holding details from GET /v1/quote/shareholders/holding. +type ShareholderDetailResponse struct { + Data json.RawMessage +} + +// ── ValuationComparisonResponse ─────────────────────────────────── + +// ValuationComparisonResponse holds the raw valuation comparison data from +// GET /v1/quote/compare/valuation. +type ValuationComparisonResponse struct { + Data json.RawMessage +} diff --git a/market/context.go b/market/context.go index 7583674..0a2108c 100644 --- a/market/context.go +++ b/market/context.go @@ -5,6 +5,7 @@ package market import ( "context" + "encoding/json" "fmt" "net/url" "strings" @@ -262,6 +263,69 @@ func (m *MarketContext) Constituent(ctx context.Context, symbol string) (*IndexC return out, nil } +// StockEvents returns market stock events (e.g., earnings, dividends, IPOs). +// +// Path: POST /v1/quote/market/stock-events +// +// markets is a list of market codes (e.g., ["HK", "US"]). +// sort controls the sort order (0 = ascending, 1 = descending). +// date is an optional date filter in "YYYY-MM-DD" format; pass an empty string to omit. +// limit controls the maximum number of events returned. +func (m *MarketContext) StockEvents(ctx context.Context, markets []string, sort uint32, date string, limit uint32) (*StockEventsResponse, error) { + body := map[string]interface{}{ + "limit": limit, + "sort": sort, + "markets": markets, + } + if date != "" { + body["date"] = date + } + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &resp); err != nil { + return nil, err + } + return &StockEventsResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// RankCategories returns the available rank categories. +// +// Path: GET /v1/quote/market/rank/categories +func (m *MarketContext) RankCategories(ctx context.Context) (*RankCategoriesResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/categories", url.Values{}, &resp); err != nil { + return nil, err + } + return &RankCategoriesResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// RankList returns the ranked stock list for a given rank key. +// +// Path: GET /v1/quote/market/rank/list +// +// key is the rank category key returned by RankCategories. +// needArticle controls whether article content is included in the response. +func (m *MarketContext) RankList(ctx context.Context, key string, needArticle bool) (*RankListResponse, error) { + needArticleStr := "false" + if needArticle { + needArticleStr = "true" + } + params := url.Values{} + params.Set("key", key) + params.Set("delay_bmp", "false") + params.Set("need_article", needArticleStr) + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/list", params, &resp); err != nil { + return nil, err + } + return &RankListResponse{Data: json.RawMessage(resp.Data)}, nil +} + // --- helpers --- // toAPIString converts a BrokerHoldingPeriod to the API's type parameter value. diff --git a/market/types.go b/market/types.go index 474b7cc..b057683 100644 --- a/market/types.go +++ b/market/types.go @@ -1,6 +1,7 @@ package market import ( + "encoding/json" "time" "github.com/shopspring/decimal" @@ -315,3 +316,21 @@ type ConstituentStock struct { // Raw trade status code TradeStatus int32 } + +// StockEventsResponse holds the raw data for market stock events from +// POST /v1/quote/market/stock-events. +type StockEventsResponse struct { + Data json.RawMessage +} + +// RankCategoriesResponse holds the raw data for rank categories from +// GET /v1/quote/market/rank/categories. +type RankCategoriesResponse struct { + Data json.RawMessage +} + +// RankListResponse holds the raw data for a rank list from +// GET /v1/quote/market/rank/list. +type RankListResponse struct { + Data json.RawMessage +} diff --git a/quote/context.go b/quote/context.go index 7024868..67981df 100644 --- a/quote/context.go +++ b/quote/context.go @@ -2,6 +2,7 @@ package quote import ( "context" + "encoding/json" "fmt" "net/url" "strings" @@ -675,6 +676,47 @@ func New(opt ...Option) (*QuoteContext, error) { return tc, nil } +// HkShortPositions returns short interest position data for a HK security. +// +// Path: GET /v1/quote/short-positions/hk +func (c *QuoteContext) HkShortPositions(ctx context.Context, symbol string, count uint32) (*HkShortPositionsResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + values := url.Values{} + values.Set("counter_id", quoteSymbolToCounterID(symbol)) + values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) + values.Set("count", fmt.Sprintf("%d", count)) + if err := c.opts.httpClient.Get(ctx, "/v1/quote/short-positions/hk", values, &resp); err != nil { + return nil, err + } + return &HkShortPositionsResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ShortTrades returns short trade records for a HK or US security. +// +// The endpoint is automatically chosen based on the symbol suffix: +// - ".HK" → GET /v1/quote/short-trades/hk +// - ".US" → GET /v1/quote/short-trades/us +func (c *QuoteContext) ShortTrades(ctx context.Context, symbol string, count uint32) (*ShortTradesResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + values := url.Values{} + values.Set("counter_id", quoteSymbolToCounterID(symbol)) + values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) + values.Set("page_size", fmt.Sprintf("%d", count)) + + path := "/v1/quote/short-trades/hk" + if strings.HasSuffix(strings.ToUpper(symbol), ".US") { + path = "/v1/quote/short-trades/us" + } + if err := c.opts.httpClient.Get(ctx, path, values, &resp); err != nil { + return nil, err + } + return &ShortTradesResponse{Data: json.RawMessage(resp.Data)}, nil +} + // quoteSymbolToCounterID converts "AAPL.US" → "ST/US/AAPL" for endpoints // that require the internal counter_id format. func quoteSymbolToCounterID(symbol string) string { diff --git a/quote/types.go b/quote/types.go index 9922cf6..a7c0c73 100644 --- a/quote/types.go +++ b/quote/types.go @@ -1,6 +1,7 @@ package quote import ( + "encoding/json" "time" quotev1 "github.com/longbridge/openapi-protobufs/gen/go/quote" @@ -776,3 +777,15 @@ func CandlestickRequestTradeSession(session CandlestickTradeSession) Candlestick req.TradeSession = int32(session) } } + +// HkShortPositionsResponse holds the raw data for HK short interest positions +// from GET /v1/quote/short-positions/hk. +type HkShortPositionsResponse struct { + Data json.RawMessage +} + +// ShortTradesResponse holds the raw data for short trade records +// from GET /v1/quote/short-trades/hk or /v1/quote/short-trades/us. +type ShortTradesResponse struct { + Data json.RawMessage +} diff --git a/screener/context.go b/screener/context.go new file mode 100644 index 0000000..877a574 --- /dev/null +++ b/screener/context.go @@ -0,0 +1,147 @@ +// Package screener provides a client for the Longbridge Screener OpenAPI. +// It covers stock screener strategies, indicator search, and pre-defined +// recommendation strategies. +package screener + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/pkg/errors" + + "github.com/longbridge/openapi-go/config" + httplib "github.com/longbridge/openapi-go/http" +) + +// ScreenerContext is a client for the Longbridge Screener OpenAPI. +// +// Example: +// +// conf, err := config.NewFromEnv() +// sctx, err := screener.NewFromCfg(conf) +// recs, err := sctx.ScreenerRecommendStrategies(context.Background()) +type ScreenerContext struct { + httpClient *httplib.Client +} + +// NewFromCfg creates a ScreenerContext from a *config.Config. +func NewFromCfg(cfg *config.Config) (*ScreenerContext, error) { + httpClient, err := httplib.NewFromCfg(cfg) + if err != nil { + return nil, errors.Wrap(err, "create http client error") + } + return &ScreenerContext{httpClient: httpClient}, nil +} + +// NewFromEnv returns a ScreenerContext configured from environment variables. +func NewFromEnv() (*ScreenerContext, error) { + cfg, err := config.NewFormEnv() + if err != nil { + return nil, errors.Wrap(err, "load config from env error") + } + return NewFromCfg(cfg) +} + +// symbolToCounterID converts a symbol like "TSLA.US" to a counter_id like +// "ST/US/TSLA". All symbols are treated as equities (ST prefix). +func symbolToCounterID(symbol string) string { + idx := strings.LastIndex(symbol, ".") + if idx < 0 { + return symbol + } + code := symbol[:idx] + market := strings.ToUpper(symbol[idx+1:]) + return fmt.Sprintf("ST/%s/%s", market, code) +} + +// ─── ScreenerRecommendStrategies ────────────────────────────────────────────── + +// ScreenerRecommendStrategies fetches the list of recommended screener strategies. +// +// Path: GET /v1/quote/screener/strategies/recommend +func (c *ScreenerContext) ScreenerRecommendStrategies(ctx context.Context) (*RecommendStrategiesResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/recommend", url.Values{}, &resp); err != nil { + return nil, err + } + return &RecommendStrategiesResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ScreenerUserStrategies ─────────────────────────────────────────────────── + +// ScreenerUserStrategies fetches the current user's saved screener strategies. +// +// Path: GET /v1/quote/screener/strategies/mine +func (c *ScreenerContext) ScreenerUserStrategies(ctx context.Context) (*UserStrategiesResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/mine", url.Values{}, &resp); err != nil { + return nil, err + } + return &UserStrategiesResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ScreenerStrategy ───────────────────────────────────────────────────────── + +// ScreenerStrategy fetches a single screener strategy by ID. +// +// Path: GET /v1/quote/screener/strategy +func (c *ScreenerContext) ScreenerStrategy(ctx context.Context, id int64) (*StrategyResponse, error) { + q := url.Values{} + q.Set("id", fmt.Sprintf("%d", id)) + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategy", q, &resp); err != nil { + return nil, err + } + return &StrategyResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ScreenerSearch ─────────────────────────────────────────────────────────── + +// ScreenerSearch executes a screener search. +// +// Path: POST /v1/quote/screener/search +// +// market is the market code, e.g. "US" or "HK". +// strategyID is optional; pass nil to search without a strategy filter. +// page and size control pagination (1-indexed). +func (c *ScreenerContext) ScreenerSearch(ctx context.Context, market string, strategyID *int64, page, size uint32) (*ScreenerSearchResponse, error) { + body := map[string]interface{}{ + "market": market, + "page": page, + "size": size, + } + if strategyID != nil { + body["strategy_id"] = *strategyID + } + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := c.httpClient.Post(ctx, "/v1/quote/screener/search", body, &resp); err != nil { + return nil, err + } + return &ScreenerSearchResponse{Data: json.RawMessage(resp.Data)}, nil +} + +// ─── ScreenerIndicators ─────────────────────────────────────────────────────── + +// ScreenerIndicators fetches the list of available screener indicators. +// +// Path: GET /v1/quote/screener/indicators +func (c *ScreenerContext) ScreenerIndicators(ctx context.Context) (*ScreenerIndicatorsResponse, error) { + var resp struct { + Data json.RawMessage `json:"data"` + } + if err := c.httpClient.Get(ctx, "/v1/quote/screener/indicators", url.Values{}, &resp); err != nil { + return nil, err + } + return &ScreenerIndicatorsResponse{Data: json.RawMessage(resp.Data)}, nil +} diff --git a/screener/types.go b/screener/types.go new file mode 100644 index 0000000..143474a --- /dev/null +++ b/screener/types.go @@ -0,0 +1,36 @@ +// Package screener provides a client for the Longbridge Screener OpenAPI. +// It covers stock screener strategies, indicator search, and pre-defined +// recommendation strategies. +package screener + +import "encoding/json" + +// RecommendStrategiesResponse holds the raw data for recommended screener +// strategies from GET /v1/quote/screener/strategies/recommend. +type RecommendStrategiesResponse struct { + Data json.RawMessage +} + +// UserStrategiesResponse holds the raw data for the current user's screener +// strategies from GET /v1/quote/screener/strategies/mine. +type UserStrategiesResponse struct { + Data json.RawMessage +} + +// StrategyResponse holds the raw data for a single screener strategy from +// GET /v1/quote/screener/strategy. +type StrategyResponse struct { + Data json.RawMessage +} + +// ScreenerSearchResponse holds the raw search results from +// POST /v1/quote/screener/search. +type ScreenerSearchResponse struct { + Data json.RawMessage +} + +// ScreenerIndicatorsResponse holds the raw list of screener indicators from +// GET /v1/quote/screener/indicators. +type ScreenerIndicatorsResponse struct { + Data json.RawMessage +} From 87233ec20dc23b7798be7e721743ab6a3d7938f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 20 May 2026 15:59:16 +0800 Subject: [PATCH 2/7] feat(quote): merge hk_short_positions into short_positions (US+HK unified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit short_positions now auto-detects market from symbol suffix: - .HK → GET /v1/quote/short-positions/hk (HKEX daily data) - other → GET /v1/quote/short-positions/us (FINRA bi-monthly data) Response is ShortPositionsResponse{Data json.RawMessage} since HK and US have different shapes. HkShortPositions and ShortPositionStats removed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- quote/context.go | 60 +++++++++++++++++------------------------------- quote/types.go | 19 ++++----------- 2 files changed, 26 insertions(+), 53 deletions(-) diff --git a/quote/context.go b/quote/context.go index 67981df..b4ff2af 100644 --- a/quote/context.go +++ b/quote/context.go @@ -524,33 +524,31 @@ func (c *QuoteContext) Filings(ctx context.Context, symbol string) (items []*Fil return } -// ShortPositions returns short interest data for a US security. -// Path: GET /v1/quote/short-positions/us -func (c *QuoteContext) ShortPositions(ctx context.Context, symbol string) (*ShortPositionStats, error) { - var resp jsontypes.ShortPositionStats +// ShortPositions returns short interest / short position data for a US or HK security. +// +// Market is auto-detected from the symbol suffix: +// - ".HK" → GET /v1/quote/short-positions/hk (HKEX daily data) +// - otherwise → GET /v1/quote/short-positions/us (FINRA bi-monthly data) +// +// count controls the number of records returned (1–100, default 20). +// Response shape differs by market; raw JSON is returned via ShortPositionsResponse.Data. +func (c *QuoteContext) ShortPositions(ctx context.Context, symbol string, count uint32) (*ShortPositionsResponse, error) { + isHK := strings.HasSuffix(strings.ToUpper(symbol), ".HK") + path := "/v1/quote/short-positions/us" + if isHK { + path = "/v1/quote/short-positions/hk" + } + var resp struct { + Data json.RawMessage `json:"data"` + } values := url.Values{} values.Set("counter_id", quoteSymbolToCounterID(symbol)) - values.Set("last_timestamp", "0") - values.Set("page_size", "100") - if err := c.opts.httpClient.Get(ctx, "/v1/quote/short-positions/us", values, &resp); err != nil { + values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) + values.Set("count", fmt.Sprintf("%d", count)) + if err := c.opts.httpClient.Get(ctx, path, values, &resp); err != nil { return nil, err } - stats := &ShortPositionStats{ - Symbol: resp.Symbol, - Sources: resp.Sources, - } - stats.Data = make([]*ShortPosition, 0, len(resp.Data)) - for _, d := range resp.Data { - stats.Data = append(stats.Data, &ShortPosition{ - Timestamp: d.Timestamp, - Rate: d.Rate, - AvgDailyShareVolume: d.AvgDailyShareVolume, - CurrentSharesShort: d.CurrentSharesShort, - DaysToCover: d.DaysToCover, - Close: d.Close, - }) - } - return stats, nil + return &ShortPositionsResponse{Data: json.RawMessage(resp.Data)}, nil } // OptionVolume returns aggregated call/put volume stats for a security. @@ -676,22 +674,6 @@ func New(opt ...Option) (*QuoteContext, error) { return tc, nil } -// HkShortPositions returns short interest position data for a HK security. -// -// Path: GET /v1/quote/short-positions/hk -func (c *QuoteContext) HkShortPositions(ctx context.Context, symbol string, count uint32) (*HkShortPositionsResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } - values := url.Values{} - values.Set("counter_id", quoteSymbolToCounterID(symbol)) - values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) - values.Set("count", fmt.Sprintf("%d", count)) - if err := c.opts.httpClient.Get(ctx, "/v1/quote/short-positions/hk", values, &resp); err != nil { - return nil, err - } - return &HkShortPositionsResponse{Data: json.RawMessage(resp.Data)}, nil -} // ShortTrades returns short trade records for a HK or US security. // diff --git a/quote/types.go b/quote/types.go index a7c0c73..38b9233 100644 --- a/quote/types.go +++ b/quote/types.go @@ -726,14 +726,11 @@ type ShortPosition struct { Close string } -// ShortPositionStats contains short interest data for a security -type ShortPositionStats struct { - // Security symbol - Symbol string - // Short interest data points - Data []*ShortPosition - // Number of data sources - Sources int32 +// ShortPositionsResponse holds raw short interest/position data for US or HK. +// Response shape differs by market: US returns a list under "list", HK returns +// an array directly. Use json.Unmarshal on Data to parse. +type ShortPositionsResponse struct { + Data json.RawMessage } // OptionVolumeStats contains aggregated call/put volume for a security @@ -778,12 +775,6 @@ func CandlestickRequestTradeSession(session CandlestickTradeSession) Candlestick } } -// HkShortPositionsResponse holds the raw data for HK short interest positions -// from GET /v1/quote/short-positions/hk. -type HkShortPositionsResponse struct { - Data json.RawMessage -} - // ShortTradesResponse holds the raw data for short trade records // from GET /v1/quote/short-trades/hk or /v1/quote/short-trades/us. type ShortTradesResponse struct { From 34028766efa0b86d098f6152f77b5ba4ab49dfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 20 May 2026 16:53:21 +0800 Subject: [PATCH 3/7] fix(fundamental,market,screener): fix JSON response parsing for raw-data APIs The Go HTTP client already extracts the `data` field from the API envelope before deserializing into the target struct. Wrapping in an intermediate struct with `json:"data"` caused double-unwrapping, producing null Data. Fix: use json.RawMessage directly as the deserialization target in ShareholderTop, ShareholderDetail, ValuationComparison, StockEvents, RankCategories, RankList, all screener methods. Also add missing json:"data" tags to market/types.go and screener/types.go response structs. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- fundamental/context.go | 12 ++++++------ market/context.go | 18 ++++++------------ market/types.go | 6 +++--- screener/context.go | 30 ++++++++++-------------------- screener/types.go | 10 +++++----- 5 files changed, 30 insertions(+), 46 deletions(-) diff --git a/fundamental/context.go b/fundamental/context.go index 8c7a704..f4233ba 100644 --- a/fundamental/context.go +++ b/fundamental/context.go @@ -1120,11 +1120,11 @@ func (c *FundamentalContext) ShareholderTop( ) (*ShareholderTopResponse, error) { q := url.Values{} q.Set("counter_id", symbolToCounterID(symbol)) - var resp jsontypes.ShareholderTopResponse + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/top", q, &resp); err != nil { return nil, err } - return &ShareholderTopResponse{Data: json.RawMessage(resp.Data)}, nil + return &ShareholderTopResponse{Data: resp}, nil } // ─── ShareholderDetail ──────────────────────────────────────────────────────── @@ -1140,11 +1140,11 @@ func (c *FundamentalContext) ShareholderDetail( q := url.Values{} q.Set("counter_id", symbolToCounterID(symbol)) q.Set("object_id", strconv.FormatInt(objectID, 10)) - var resp jsontypes.ShareholderDetailResponse + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/holding", q, &resp); err != nil { return nil, err } - return &ShareholderDetailResponse{Data: json.RawMessage(resp.Data)}, nil + return &ShareholderDetailResponse{Data: resp}, nil } // ─── ValuationComparison ────────────────────────────────────────────────────── @@ -1175,11 +1175,11 @@ func (c *FundamentalContext) ValuationComparison( q.Set("counter_id", symbolToCounterID(symbol)) q.Set("currency", currency) q.Set("comparison_counter_ids", string(counterIDsJSON)) - var resp jsontypes.ValuationComparisonResponse + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/compare/valuation", q, &resp); err != nil { return nil, err } - return &ValuationComparisonResponse{Data: json.RawMessage(resp.Data)}, nil + return &ValuationComparisonResponse{Data: resp}, nil } // parseTimestampNumber converts a json.Number (int or quoted string) to int64 Unix seconds. diff --git a/market/context.go b/market/context.go index 0a2108c..8921f58 100644 --- a/market/context.go +++ b/market/context.go @@ -280,26 +280,22 @@ func (m *MarketContext) StockEvents(ctx context.Context, markets []string, sort if date != "" { body["date"] = date } - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &resp); err != nil { return nil, err } - return &StockEventsResponse{Data: json.RawMessage(resp.Data)}, nil + return &StockEventsResponse{Data: resp}, nil } // RankCategories returns the available rank categories. // // Path: GET /v1/quote/market/rank/categories func (m *MarketContext) RankCategories(ctx context.Context) (*RankCategoriesResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/categories", url.Values{}, &resp); err != nil { return nil, err } - return &RankCategoriesResponse{Data: json.RawMessage(resp.Data)}, nil + return &RankCategoriesResponse{Data: resp}, nil } // RankList returns the ranked stock list for a given rank key. @@ -317,13 +313,11 @@ func (m *MarketContext) RankList(ctx context.Context, key string, needArticle bo params.Set("key", key) params.Set("delay_bmp", "false") params.Set("need_article", needArticleStr) - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/list", params, &resp); err != nil { return nil, err } - return &RankListResponse{Data: json.RawMessage(resp.Data)}, nil + return &RankListResponse{Data: resp}, nil } // --- helpers --- diff --git a/market/types.go b/market/types.go index b057683..251f255 100644 --- a/market/types.go +++ b/market/types.go @@ -320,17 +320,17 @@ type ConstituentStock struct { // StockEventsResponse holds the raw data for market stock events from // POST /v1/quote/market/stock-events. type StockEventsResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // RankCategoriesResponse holds the raw data for rank categories from // GET /v1/quote/market/rank/categories. type RankCategoriesResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // RankListResponse holds the raw data for a rank list from // GET /v1/quote/market/rank/list. type RankListResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } diff --git a/screener/context.go b/screener/context.go index 877a574..ce0990c 100644 --- a/screener/context.go +++ b/screener/context.go @@ -63,13 +63,11 @@ func symbolToCounterID(symbol string) string { // // Path: GET /v1/quote/screener/strategies/recommend func (c *ScreenerContext) ScreenerRecommendStrategies(ctx context.Context) (*RecommendStrategiesResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/recommend", url.Values{}, &resp); err != nil { return nil, err } - return &RecommendStrategiesResponse{Data: json.RawMessage(resp.Data)}, nil + return &RecommendStrategiesResponse{Data: resp}, nil } // ─── ScreenerUserStrategies ─────────────────────────────────────────────────── @@ -78,13 +76,11 @@ func (c *ScreenerContext) ScreenerRecommendStrategies(ctx context.Context) (*Rec // // Path: GET /v1/quote/screener/strategies/mine func (c *ScreenerContext) ScreenerUserStrategies(ctx context.Context) (*UserStrategiesResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/mine", url.Values{}, &resp); err != nil { return nil, err } - return &UserStrategiesResponse{Data: json.RawMessage(resp.Data)}, nil + return &UserStrategiesResponse{Data: resp}, nil } // ─── ScreenerStrategy ───────────────────────────────────────────────────────── @@ -95,13 +91,11 @@ func (c *ScreenerContext) ScreenerUserStrategies(ctx context.Context) (*UserStra func (c *ScreenerContext) ScreenerStrategy(ctx context.Context, id int64) (*StrategyResponse, error) { q := url.Values{} q.Set("id", fmt.Sprintf("%d", id)) - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategy", q, &resp); err != nil { return nil, err } - return &StrategyResponse{Data: json.RawMessage(resp.Data)}, nil + return &StrategyResponse{Data: resp}, nil } // ─── ScreenerSearch ─────────────────────────────────────────────────────────── @@ -122,13 +116,11 @@ func (c *ScreenerContext) ScreenerSearch(ctx context.Context, market string, str if strategyID != nil { body["strategy_id"] = *strategyID } - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := c.httpClient.Post(ctx, "/v1/quote/screener/search", body, &resp); err != nil { return nil, err } - return &ScreenerSearchResponse{Data: json.RawMessage(resp.Data)}, nil + return &ScreenerSearchResponse{Data: resp}, nil } // ─── ScreenerIndicators ─────────────────────────────────────────────────────── @@ -137,11 +129,9 @@ func (c *ScreenerContext) ScreenerSearch(ctx context.Context, market string, str // // Path: GET /v1/quote/screener/indicators func (c *ScreenerContext) ScreenerIndicators(ctx context.Context) (*ScreenerIndicatorsResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } + var resp json.RawMessage if err := c.httpClient.Get(ctx, "/v1/quote/screener/indicators", url.Values{}, &resp); err != nil { return nil, err } - return &ScreenerIndicatorsResponse{Data: json.RawMessage(resp.Data)}, nil + return &ScreenerIndicatorsResponse{Data: resp}, nil } diff --git a/screener/types.go b/screener/types.go index 143474a..dba28a8 100644 --- a/screener/types.go +++ b/screener/types.go @@ -8,29 +8,29 @@ import "encoding/json" // RecommendStrategiesResponse holds the raw data for recommended screener // strategies from GET /v1/quote/screener/strategies/recommend. type RecommendStrategiesResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // UserStrategiesResponse holds the raw data for the current user's screener // strategies from GET /v1/quote/screener/strategies/mine. type UserStrategiesResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // StrategyResponse holds the raw data for a single screener strategy from // GET /v1/quote/screener/strategy. type StrategyResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // ScreenerSearchResponse holds the raw search results from // POST /v1/quote/screener/search. type ScreenerSearchResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } // ScreenerIndicatorsResponse holds the raw list of screener indicators from // GET /v1/quote/screener/indicators. type ScreenerIndicatorsResponse struct { - Data json.RawMessage + Data json.RawMessage `json:"data"` } From 3184e1195f982eef28490ffea16f0ff04a148b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Thu, 21 May 2026 10:11:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?rename(market):=20StockEvents=20=E2=86=92?= =?UTF-8?q?=20TopMovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames method and response type: - `StockEvents` → `TopMovers` (method on MarketContext) - `StockEventsResponse` → `TopMoversResponse` (response type) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CHANGELOG.md | 10 +++++++--- market/context.go | 8 ++++---- market/types.go | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1629ee6..92dd450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,13 @@ - `ShortTrades` — GET `/v1/quote/short-trades/hk` or `/us` (auto-detected) - **Go:** Three new `MarketContext` methods: - - `TopMovers` — POST `/v1/quote/market/stock-events` - - `RankCategories` — GET `/v1/quote/market/rank/categories` - - `RankList` — GET `/v1/quote/market/rank/list` + - `TopMovers` — POST `/v1/quote/market/stock-events`: top movers (stocks with unusual price movements) filtered by market codes, sort order, optional date, and limit. + - `RankCategories` — GET `/v1/quote/market/rank/categories`: available rank category keys. + - `RankList` — GET `/v1/quote/market/rank/list`: ranked stock list for a given rank key. + +### Breaking changes + +- **Go:** `MarketContext.StockEvents` renamed to `TopMovers`; `StockEventsResponse` renamed to `TopMoversResponse`. ## [v4.1.0] - 2026-05-14 diff --git a/market/context.go b/market/context.go index 8921f58..07e7102 100644 --- a/market/context.go +++ b/market/context.go @@ -263,15 +263,15 @@ func (m *MarketContext) Constituent(ctx context.Context, symbol string) (*IndexC return out, nil } -// StockEvents returns market stock events (e.g., earnings, dividends, IPOs). +// TopMovers returns top movers (stocks with unusual price movements) across one or more markets. // // Path: POST /v1/quote/market/stock-events // // markets is a list of market codes (e.g., ["HK", "US"]). // sort controls the sort order (0 = ascending, 1 = descending). // date is an optional date filter in "YYYY-MM-DD" format; pass an empty string to omit. -// limit controls the maximum number of events returned. -func (m *MarketContext) StockEvents(ctx context.Context, markets []string, sort uint32, date string, limit uint32) (*StockEventsResponse, error) { +// limit controls the maximum number of results returned. +func (m *MarketContext) TopMovers(ctx context.Context, markets []string, sort uint32, date string, limit uint32) (*TopMoversResponse, error) { body := map[string]interface{}{ "limit": limit, "sort": sort, @@ -284,7 +284,7 @@ func (m *MarketContext) StockEvents(ctx context.Context, markets []string, sort if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &resp); err != nil { return nil, err } - return &StockEventsResponse{Data: resp}, nil + return &TopMoversResponse{Data: resp}, nil } // RankCategories returns the available rank categories. diff --git a/market/types.go b/market/types.go index 251f255..45d8268 100644 --- a/market/types.go +++ b/market/types.go @@ -317,9 +317,9 @@ type ConstituentStock struct { TradeStatus int32 } -// StockEventsResponse holds the raw data for market stock events from +// TopMoversResponse holds the raw data for market top movers from // POST /v1/quote/market/stock-events. -type StockEventsResponse struct { +type TopMoversResponse struct { Data json.RawMessage `json:"data"` } From a18b1cafa6d02674b6da6b5378955c3cd5ccbf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Thu, 21 May 2026 13:37:57 +0800 Subject: [PATCH 5/7] fix(changelog): correct ShortPositions description (no separate HkShortPositions method) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dd450..bcc959b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,8 @@ - `ValuationComparison` — GET `/v1/quote/compare/valuation` - **Go:** Two new `QuoteContext` methods: - - `ShortPositions(ctx, symbol, count)` — GET `/v1/quote/short-positions/hk` or `/us` (auto-detected from symbol suffix) - - `ShortTrades` — GET `/v1/quote/short-trades/hk` or `/us` (auto-detected) + - `ShortPositions(ctx, symbol, count)` — GET `/v1/quote/short-positions/hk` or `/v1/quote/short-positions/us` (auto-detected from symbol suffix): short interest / position data for HK or US securities. + - `ShortTrades` — GET `/v1/quote/short-trades/hk` or `/v1/quote/short-trades/us` (auto-detected by `.HK`/`.US` suffix): short trade records. - **Go:** Three new `MarketContext` methods: - `TopMovers` — POST `/v1/quote/market/stock-events`: top movers (stocks with unusual price movements) filtered by market codes, sort order, optional date, and limit. From 95ae67fe45e40f26a1e45ed5e1eca24e9c8d55c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Thu, 21 May 2026 15:24:02 +0800 Subject: [PATCH 6/7] feat: convert counter_id to symbol and timestamp to RFC 3339 in new APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace json.RawMessage returns with typed structs across 5 new APIs: - ShortPositions/ShortTrades: []*ShortPositionsItem / []*ShortTradesItem with unified US+HK fields, timestamp → RFC 3339 - RankList: RankListResponse{Bmp, Lists []*RankListItem}, counter_id → symbol - TopMovers: TopMoversResponse{Events []*TopMoversEvent, NextParams}, counter_id → symbol, timestamp → RFC 3339 - ValuationComparison: ValuationComparisonResponse{List []*ValuationComparisonItem}, counter_id → symbol, history date → RFC 3339 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CHANGELOG.md | 8 +++ cmd/test_new_apis/main.go | 2 +- fundamental/context.go | 68 +++++++++++++++++++- fundamental/types.go | 34 +++++++++- market/context.go | 130 +++++++++++++++++++++++++++++++++++--- market/types.go | 62 ++++++++++++++++-- quote/context.go | 70 ++++++++++++++++---- quote/types.go | 61 ++++++++++++------ 8 files changed, 385 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc959b..8940afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changed + +- **Go:** `ShortPositionsResponse.Data` changed from `json.RawMessage` to `[]*ShortPositionsItem` — typed struct with unified US+HK fields; `timestamp` converted to RFC 3339. +- **Go:** `ShortTradesResponse.Data` changed from `json.RawMessage` to `[]*ShortTradesItem` — typed struct with unified US+HK fields; `timestamp` converted to RFC 3339. +- **Go:** `RankListResponse` changed from `{Data json.RawMessage}` to `{Bmp bool, Lists []*RankListItem}` — `counter_id` converted to symbol. +- **Go:** `TopMoversResponse` changed from `{Data json.RawMessage}` to `{Events []*TopMoversEvent, NextParams json.RawMessage}` — `counter_id` converted to symbol, `timestamp` converted to RFC 3339. +- **Go:** `ValuationComparisonResponse` changed from `{Data json.RawMessage}` to `{List []*ValuationComparisonItem}` — `counter_id` converted to symbol, history `date` converted to RFC 3339. + ### Added - **Go:** Six new `FundamentalContext` methods (merged from PR #91): diff --git a/cmd/test_new_apis/main.go b/cmd/test_new_apis/main.go index 8d1f7fa..f5d8779 100644 --- a/cmd/test_new_apis/main.go +++ b/cmd/test_new_apis/main.go @@ -584,7 +584,7 @@ func main() { } else { defer qctx.Close() t.check("ShortPositions(AAPL.US)", func() error { - r, err := qctx.ShortPositions(ctx, "AAPL.US") + r, err := qctx.ShortPositions(ctx, "AAPL.US", 20) if err != nil { return err } diff --git a/fundamental/context.go b/fundamental/context.go index f4233ba..b1f31de 100644 --- a/fundamental/context.go +++ b/fundamental/context.go @@ -1175,11 +1175,73 @@ func (c *FundamentalContext) ValuationComparison( q.Set("counter_id", symbolToCounterID(symbol)) q.Set("currency", currency) q.Set("comparison_counter_ids", string(counterIDsJSON)) - var resp json.RawMessage - if err := c.httpClient.Get(ctx, "/v1/quote/compare/valuation", q, &resp); err != nil { + var raw struct { + List []struct { + CounterID string `json:"counter_id"` + Name string `json:"name"` + Currency string `json:"currency"` + MarketValue string `json:"market_value"` + PriceClose string `json:"price_close"` + Pe string `json:"pe"` + Pb string `json:"pb"` + Ps string `json:"ps"` + Roe string `json:"roe"` + Eps string `json:"eps"` + Bps string `json:"bps"` + Dps string `json:"dps"` + DivYld string `json:"div_yld"` + Assets string `json:"assets"` + History []struct { + Date string `json:"date"` + Pe string `json:"pe"` + Pb string `json:"pb"` + Ps string `json:"ps"` + } `json:"history"` + } `json:"list"` + } + if err := c.httpClient.Get(ctx, "/v1/quote/compare/valuation", q, &raw); err != nil { return nil, err } - return &ValuationComparisonResponse{Data: resp}, nil + items := make([]*ValuationComparisonItem, 0, len(raw.List)) + for _, it := range raw.List { + history := make([]*ValuationHistoryPoint, 0, len(it.History)) + for _, h := range it.History { + history = append(history, &ValuationHistoryPoint{ + Date: unixSecsToRFC3339(h.Date), + Pe: h.Pe, + Pb: h.Pb, + Ps: h.Ps, + }) + } + items = append(items, &ValuationComparisonItem{ + Symbol: counterIDToSymbol(it.CounterID), + Name: it.Name, + Currency: it.Currency, + MarketValue: it.MarketValue, + PriceClose: it.PriceClose, + Pe: it.Pe, + Pb: it.Pb, + Ps: it.Ps, + Roe: it.Roe, + Eps: it.Eps, + Bps: it.Bps, + Dps: it.Dps, + DivYld: it.DivYld, + Assets: it.Assets, + History: history, + }) + } + return &ValuationComparisonResponse{List: items}, nil +} + +// unixSecsToRFC3339 converts a Unix-seconds string to an RFC 3339 timestamp. +// If the string cannot be parsed it is returned unchanged. +func unixSecsToRFC3339(s string) string { + ts, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return s + } + return time.Unix(ts, 0).UTC().Format(time.RFC3339) } // parseTimestampNumber converts a json.Number (int or quoted string) to int64 Unix seconds. diff --git a/fundamental/types.go b/fundamental/types.go index c2ae75f..8ea8c96 100644 --- a/fundamental/types.go +++ b/fundamental/types.go @@ -1250,8 +1250,36 @@ type ShareholderDetailResponse struct { // ── ValuationComparisonResponse ─────────────────────────────────── -// ValuationComparisonResponse holds the raw valuation comparison data from -// GET /v1/quote/compare/valuation. +// ValuationHistoryPoint is one historical valuation data point. +type ValuationHistoryPoint struct { + // Date — RFC 3339 (converted from Unix timestamp) + Date string + Pe string + Pb string + Ps string +} + +// ValuationComparisonItem is one security in the valuation comparison. +type ValuationComparisonItem struct { + // Symbol — converted from counter_id (e.g. "AAPL.US") + Symbol string + Name string + Currency string + MarketValue string + PriceClose string + Pe string + Pb string + Ps string + Roe string + Eps string + Bps string + Dps string + DivYld string + Assets string + History []*ValuationHistoryPoint +} + +// ValuationComparisonResponse is the response for FundamentalContext.ValuationComparison. type ValuationComparisonResponse struct { - Data json.RawMessage + List []*ValuationComparisonItem } diff --git a/market/context.go b/market/context.go index 07e7102..ca7ded1 100644 --- a/market/context.go +++ b/market/context.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "time" @@ -280,11 +281,50 @@ func (m *MarketContext) TopMovers(ctx context.Context, markets []string, sort ui if date != "" { body["date"] = date } - var resp json.RawMessage - if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &resp); err != nil { + var raw struct { + Events []map[string]interface{} `json:"events"` + NextParams json.RawMessage `json:"next_params"` + } + if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &raw); err != nil { return nil, err } - return &TopMoversResponse{Data: resp}, nil + events := make([]*TopMoversEvent, 0, len(raw.Events)) + for _, e := range raw.Events { + var stock TopMoversStock + if s, ok := e["stock"].(map[string]interface{}); ok { + stock = TopMoversStock{ + Symbol: counterIDToSymbol(strVal(s, "counter_id")), + Code: strVal(s, "code"), + Name: strVal(s, "name"), + FullName: strVal(s, "full_name"), + Change: strVal(s, "change"), + LastDone: strVal(s, "last_done"), + Market: strVal(s, "market"), + Logo: strVal(s, "logo"), + } + if labels, ok := s["labels"].([]interface{}); ok { + for _, l := range labels { + if ls, ok := l.(string); ok { + stock.Labels = append(stock.Labels, ls) + } + } + } + } + var postRaw json.RawMessage + if postVal, ok := e["post"]; ok && postVal != nil { + if b, err := json.Marshal(postVal); err == nil { + postRaw = json.RawMessage(b) + } + } + events = append(events, &TopMoversEvent{ + Timestamp: tsToRFC3339(e["timestamp"]), + AlertReason: strVal(e, "alert_reason"), + AlertType: int64Val(e, "alert_type"), + Stock: stock, + Post: postRaw, + }) + } + return &TopMoversResponse{Events: events, NextParams: raw.NextParams}, nil } // RankCategories returns the available rank categories. @@ -313,11 +353,35 @@ func (m *MarketContext) RankList(ctx context.Context, key string, needArticle bo params.Set("key", key) params.Set("delay_bmp", "false") params.Set("need_article", needArticleStr) - var resp json.RawMessage - if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/list", params, &resp); err != nil { + var raw struct { + Bmp bool `json:"bmp"` + Lists []map[string]interface{} `json:"lists"` + } + if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/list", params, &raw); err != nil { return nil, err } - return &RankListResponse{Data: resp}, nil + items := make([]*RankListItem, 0, len(raw.Lists)) + for _, r := range raw.Lists { + items = append(items, &RankListItem{ + Symbol: counterIDToSymbol(strVal(r, "counter_id")), + Code: strVal(r, "code"), + Name: strVal(r, "name"), + LastDone: strVal(r, "last_done"), + Chg: strVal(r, "chg"), + Change: strVal(r, "change"), + Inflow: strVal(r, "inflow"), + MarketCap: strVal(r, "market_cap"), + Industry: strVal(r, "industry"), + PrePostPrice: strVal(r, "pre_post_price"), + PrePostChg: strVal(r, "pre_post_chg"), + Amplitude: strVal(r, "amplitude"), + FiveDayChg: strVal(r, "five_day_chg"), + TurnoverRate: strVal(r, "turnover_rate"), + VolumeRate: strVal(r, "volume_rate"), + PbTtm: strVal(r, "pb_ttm"), + }) + } + return &RankListResponse{Bmp: raw.Bmp, Lists: items}, nil } // --- helpers --- @@ -352,9 +416,15 @@ func indexSymbolToCounterID(symbol string) string { return symbol } -// counterIDToSymbol converts a counter ID like "700_HK" back to a symbol "700.HK". +// counterIDToSymbol converts a counter ID back to a symbol. +// Handles both "ST/US/AAPL" → "AAPL.US" and legacy "700_HK" → "700.HK". func counterIDToSymbol(counterID string) string { - // Find the last underscore that separates market suffix + // Handle ST/MARKET/CODE format (from rank/top-movers endpoints) + parts := strings.SplitN(counterID, "/", 3) + if len(parts) == 3 { + return fmt.Sprintf("%s.%s", parts[2], parts[1]) + } + // Fallback: find the last underscore that separates market suffix idx := strings.LastIndex(counterID, "_") if idx > 0 { return counterID[:idx] + "." + counterID[idx+1:] @@ -362,6 +432,50 @@ func counterIDToSymbol(counterID string) string { return counterID } +// strVal extracts a string value from a map[string]interface{}. +func strVal(m map[string]interface{}, key string) string { + v, ok := m[key] + if !ok { + return "" + } + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + default: + return fmt.Sprintf("%v", v) + } +} + +// int64Val extracts an int64 value from a map[string]interface{}. +func int64Val(m map[string]interface{}, key string) int64 { + v, ok := m[key] + if !ok { + return 0 + } + switch x := v.(type) { + case float64: + return int64(x) + case int64: + return x + } + return 0 +} + +// tsToRFC3339 converts a timestamp value (float64 or string) to RFC 3339 format. +func tsToRFC3339(v interface{}) string { + switch x := v.(type) { + case float64: + return time.Unix(int64(x), 0).UTC().Format(time.RFC3339) + case string: + if ts, err := strconv.ParseInt(x, 10, 64); err == nil { + return time.Unix(ts, 0).UTC().Format(time.RFC3339) + } + } + return "" +} + // parseOptionalDecimal parses a decimal string, returning nil if the string is empty or zero-ish. func parseOptionalDecimal(s string) *decimal.Decimal { if s == "" { diff --git a/market/types.go b/market/types.go index 45d8268..3e810f5 100644 --- a/market/types.go +++ b/market/types.go @@ -7,6 +7,55 @@ import ( "github.com/shopspring/decimal" ) +// RankListItem is one item in the popularity rank list. +type RankListItem struct { + // Symbol — converted from counter_id (e.g. "MU.US") + Symbol string + // Code — ticker code (e.g. "MU") + Code string + Name string + LastDone string + // Chg — price change as decimal ratio (e.g. 0.0252 = +2.52%) + Chg string + // Change — absolute price change + Change string + Inflow string + MarketCap string + Industry string + PrePostPrice string + PrePostChg string + Amplitude string + FiveDayChg string + TurnoverRate string + VolumeRate string + PbTtm string +} + +// TopMoversStock holds stock info for a top-movers event. +type TopMoversStock struct { + // Symbol — converted from counter_id + Symbol string + Code string + Name string + FullName string + Change string + LastDone string + Market string + Labels []string + Logo string +} + +// TopMoversEvent is one top-movers event. +type TopMoversEvent struct { + // Timestamp — RFC 3339 + Timestamp string + AlertReason string + AlertType int64 + Stock TopMoversStock + // Post — associated news article (raw JSON, complex structure; nil when no news) + Post json.RawMessage +} + // MarketStatusResponse holds the current trading status for all markets. type MarketStatusResponse struct { // Per-market trading status items @@ -317,10 +366,11 @@ type ConstituentStock struct { TradeStatus int32 } -// TopMoversResponse holds the raw data for market top movers from -// POST /v1/quote/market/stock-events. +// TopMoversResponse is the response for MarketContext.TopMovers. type TopMoversResponse struct { - Data json.RawMessage `json:"data"` + Events []*TopMoversEvent + // NextParams — pagination cursor; pass to next call to get next page + NextParams json.RawMessage } // RankCategoriesResponse holds the raw data for rank categories from @@ -329,8 +379,8 @@ type RankCategoriesResponse struct { Data json.RawMessage `json:"data"` } -// RankListResponse holds the raw data for a rank list from -// GET /v1/quote/market/rank/list. +// RankListResponse is the response for MarketContext.RankList. type RankListResponse struct { - Data json.RawMessage `json:"data"` + Bmp bool + Lists []*RankListItem } diff --git a/quote/context.go b/quote/context.go index b4ff2af..887103e 100644 --- a/quote/context.go +++ b/quote/context.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "time" @@ -531,24 +532,35 @@ func (c *QuoteContext) Filings(ctx context.Context, symbol string) (items []*Fil // - otherwise → GET /v1/quote/short-positions/us (FINRA bi-monthly data) // // count controls the number of records returned (1–100, default 20). -// Response shape differs by market; raw JSON is returned via ShortPositionsResponse.Data. func (c *QuoteContext) ShortPositions(ctx context.Context, symbol string, count uint32) (*ShortPositionsResponse, error) { isHK := strings.HasSuffix(strings.ToUpper(symbol), ".HK") path := "/v1/quote/short-positions/us" if isHK { path = "/v1/quote/short-positions/hk" } - var resp struct { - Data json.RawMessage `json:"data"` - } values := url.Values{} values.Set("counter_id", quoteSymbolToCounterID(symbol)) values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) values.Set("count", fmt.Sprintf("%d", count)) - if err := c.opts.httpClient.Get(ctx, path, values, &resp); err != nil { + var raw []map[string]json.RawMessage + if err := c.opts.httpClient.Get(ctx, path, values, &raw); err != nil { return nil, err } - return &ShortPositionsResponse{Data: json.RawMessage(resp.Data)}, nil + items := make([]*ShortPositionsItem, 0, len(raw)) + for _, r := range raw { + items = append(items, &ShortPositionsItem{ + Timestamp: unixSecsToRFC3339(rawStr(r, "timestamp")), + Rate: rawStr(r, "rate"), + Close: rawStr(r, "close"), + CurrentSharesShort: rawStr(r, "current_shares_short"), + AvgDailyShareVolume: rawStr(r, "avg_daily_share_volume"), + DaysToCover: rawStr(r, "days_to_cover"), + Amount: rawStr(r, "amount"), + Balance: rawStr(r, "balance"), + Cost: rawStr(r, "cost"), + }) + } + return &ShortPositionsResponse{Data: items}, nil } // OptionVolume returns aggregated call/put volume stats for a security. @@ -681,9 +693,6 @@ func New(opt ...Option) (*QuoteContext, error) { // - ".HK" → GET /v1/quote/short-trades/hk // - ".US" → GET /v1/quote/short-trades/us func (c *QuoteContext) ShortTrades(ctx context.Context, symbol string, count uint32) (*ShortTradesResponse, error) { - var resp struct { - Data json.RawMessage `json:"data"` - } values := url.Values{} values.Set("counter_id", quoteSymbolToCounterID(symbol)) values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) @@ -693,10 +702,24 @@ func (c *QuoteContext) ShortTrades(ctx context.Context, symbol string, count uin if strings.HasSuffix(strings.ToUpper(symbol), ".US") { path = "/v1/quote/short-trades/us" } - if err := c.opts.httpClient.Get(ctx, path, values, &resp); err != nil { + var raw []map[string]json.RawMessage + if err := c.opts.httpClient.Get(ctx, path, values, &raw); err != nil { return nil, err } - return &ShortTradesResponse{Data: json.RawMessage(resp.Data)}, nil + items := make([]*ShortTradesItem, 0, len(raw)) + for _, r := range raw { + items = append(items, &ShortTradesItem{ + Timestamp: unixSecsToRFC3339(rawStr(r, "timestamp")), + Rate: rawStr(r, "rate"), + Close: rawStr(r, "close"), + NusAmount: rawStr(r, "nus_amount"), + NyAmount: rawStr(r, "ny_amount"), + TotalAmount: rawStr(r, "total_amount"), + Amount: rawStr(r, "amount"), + Balance: rawStr(r, "balance"), + }) + } + return &ShortTradesResponse{Data: items}, nil } // quoteSymbolToCounterID converts "AAPL.US" → "ST/US/AAPL" for endpoints @@ -708,3 +731,28 @@ func quoteSymbolToCounterID(symbol string) string { } return fmt.Sprintf("ST/%s/%s", strings.ToUpper(symbol[idx+1:]), symbol[:idx]) } + +// rawStr extracts a string value from a map of raw JSON values. +// If the value is a JSON string it is unquoted; otherwise the raw bytes are +// returned with surrounding quotes stripped. +func rawStr(m map[string]json.RawMessage, key string) string { + v, ok := m[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s + } + return strings.Trim(string(v), `"`) +} + +// unixSecsToRFC3339 converts a Unix-seconds string to an RFC 3339 timestamp. +// If the string cannot be parsed it is returned unchanged. +func unixSecsToRFC3339(s string) string { + ts, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return s + } + return time.Unix(ts, 0).UTC().Format(time.RFC3339) +} diff --git a/quote/types.go b/quote/types.go index 38b9233..0082a16 100644 --- a/quote/types.go +++ b/quote/types.go @@ -1,7 +1,6 @@ package quote import ( - "encoding/json" "time" quotev1 "github.com/longbridge/openapi-protobufs/gen/go/quote" @@ -710,27 +709,34 @@ func (m PinnedMode) String() string { return "add" } -// ShortPosition is a single short interest data point -type ShortPosition struct { - // Settlement date (unix timestamp string) +// ShortPositionsItem is one short-position record, unified for US and HK. +// US-specific fields (CurrentSharesShort, AvgDailyShareVolume, DaysToCover) +// are empty for HK records. HK-specific fields (Amount, Balance, Cost) are +// empty for US records. +type ShortPositionsItem struct { + // Timestamp — RFC 3339 (e.g. "2024-01-15T00:00:00Z") Timestamp string - // Short interest as a ratio of float shares + // Rate — short ratio Rate string - // Average daily share volume - AvgDailyShareVolume string - // Current shares short + // Close — closing price + Close string + // [US only] CurrentSharesShort — number of short shares outstanding CurrentSharesShort string - // Days to cover (short ratio) + // [US only] AvgDailyShareVolume — average daily share volume + AvgDailyShareVolume string + // [US only] DaysToCover — days-to-cover ratio DaysToCover string - // Closing price on the settlement date - Close string + // [HK only] Amount — short sale amount (HKD) + Amount string + // [HK only] Balance — short position balance + Balance string + // [HK only] Cost — closing price (HK naming) + Cost string } -// ShortPositionsResponse holds raw short interest/position data for US or HK. -// Response shape differs by market: US returns a list under "list", HK returns -// an array directly. Use json.Unmarshal on Data to parse. +// ShortPositionsResponse is the response for QuoteContext.ShortPositions. type ShortPositionsResponse struct { - Data json.RawMessage + Data []*ShortPositionsItem } // OptionVolumeStats contains aggregated call/put volume for a security @@ -775,8 +781,27 @@ func CandlestickRequestTradeSession(session CandlestickTradeSession) Candlestick } } -// ShortTradesResponse holds the raw data for short trade records -// from GET /v1/quote/short-trades/hk or /v1/quote/short-trades/us. +// ShortTradesItem is one short-trade record, unified for US and HK. +type ShortTradesItem struct { + // Timestamp — RFC 3339 + Timestamp string + // Rate — short ratio + Rate string + // Close — closing price + Close string + // [US only] NusAmount — NASDAQ short sale volume + NusAmount string + // [US only] NyAmount — NYSE short sale volume + NyAmount string + // [US only] TotalAmount — total trading volume + TotalAmount string + // [HK only] Amount — short sale turnover amount (HKD) + Amount string + // [HK only] Balance — short position balance + Balance string +} + +// ShortTradesResponse is the response for QuoteContext.ShortTrades. type ShortTradesResponse struct { - Data json.RawMessage + Data []*ShortTradesItem } From f034318ed79a736e20ef902277defa8b01d7bb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Thu, 21 May 2026 15:39:08 +0800 Subject: [PATCH 7/7] fix(quote): fix short_positions/short_trades response parsing API wraps the array in {"counter_id":"...","data":[...]} object. Changed both methods to use an outer struct, then extract inner data. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- quote/context.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/quote/context.go b/quote/context.go index 887103e..3ff8687 100644 --- a/quote/context.go +++ b/quote/context.go @@ -542,12 +542,16 @@ func (c *QuoteContext) ShortPositions(ctx context.Context, symbol string, count values.Set("counter_id", quoteSymbolToCounterID(symbol)) values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) values.Set("count", fmt.Sprintf("%d", count)) - var raw []map[string]json.RawMessage - if err := c.opts.httpClient.Get(ctx, path, values, &raw); err != nil { + // Response: {"counter_id": "ST/US/AAPL", "data": [{...}]} + var outer struct { + CounterID string `json:"counter_id"` + Data []map[string]json.RawMessage `json:"data"` + } + if err := c.opts.httpClient.Get(ctx, path, values, &outer); err != nil { return nil, err } - items := make([]*ShortPositionsItem, 0, len(raw)) - for _, r := range raw { + items := make([]*ShortPositionsItem, 0, len(outer.Data)) + for _, r := range outer.Data { items = append(items, &ShortPositionsItem{ Timestamp: unixSecsToRFC3339(rawStr(r, "timestamp")), Rate: rawStr(r, "rate"), @@ -702,12 +706,16 @@ func (c *QuoteContext) ShortTrades(ctx context.Context, symbol string, count uin if strings.HasSuffix(strings.ToUpper(symbol), ".US") { path = "/v1/quote/short-trades/us" } - var raw []map[string]json.RawMessage - if err := c.opts.httpClient.Get(ctx, path, values, &raw); err != nil { + // Response: {"counter_id": "ST/HK/700", "data": [{...}]} + var outer struct { + CounterID string `json:"counter_id"` + Data []map[string]json.RawMessage `json:"data"` + } + if err := c.opts.httpClient.Get(ctx, path, values, &outer); err != nil { return nil, err } - items := make([]*ShortTradesItem, 0, len(raw)) - for _, r := range raw { + items := make([]*ShortTradesItem, 0, len(outer.Data)) + for _, r := range outer.Data { items = append(items, &ShortTradesItem{ Timestamp: unixSecsToRFC3339(rawStr(r, "timestamp")), Rate: rawStr(r, "rate"),