Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## [Unreleased]

### Added

- **Go:** New `screener` package with `ScreenerContext` (5 methods):
- `ScreenerRecommendStrategies` — GET `/v1/quote/screener/strategies/recommend`: pre-defined recommended screener strategies.
- `ScreenerUserStrategies` — GET `/v1/quote/screener/strategies/mine`: the current user's saved screener strategies.
- `ScreenerStrategy` — GET `/v1/quote/screener/strategy`: single strategy by ID.
- `ScreenerSearch` — POST `/v1/quote/screener/search`: paginated stock screener search with optional strategy filter.
- `ScreenerIndicators` — GET `/v1/quote/screener/indicators`: list of all available screener indicators.

- **Go:** Three new `FundamentalContext` methods:
- `ShareholderTop` — GET `/v1/quote/shareholders/top`: top shareholders list for a security.
- `ShareholderDetail` — GET `/v1/quote/shareholders/holding`: holding detail for a specific shareholder by object ID.
- `ValuationComparison` — GET `/v1/quote/compare/valuation`: cross-security valuation comparison; `comparisonSymbols` are serialized as a JSON array string in `comparison_counter_ids`.

- **Go:** Two new `QuoteContext` methods:
- `HkShortPositions` — GET `/v1/quote/short-positions/hk`: HK short interest positions with configurable count.
- `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:
- `StockEvents` — POST `/v1/quote/market/stock-events`: market stock events 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.

## [v4.1.0] - 2026-05-14

### Added
Expand Down
73 changes: 73 additions & 0 deletions fundamental/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 json.RawMessage
if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/top", q, &resp); err != nil {
return nil, err
}
return &ShareholderTopResponse{Data: resp}, 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 json.RawMessage
if err := c.httpClient.Get(ctx, "/v1/quote/shareholders/holding", q, &resp); err != nil {
return nil, err
}
return &ShareholderDetailResponse{Data: resp}, 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 json.RawMessage
if err := c.httpClient.Get(ctx, "/v1/quote/compare/valuation", q, &resp); err != nil {
return nil, err
}
return &ValuationComparisonResponse{Data: resp}, nil
}

// parseTimestampNumber converts a json.Number (int or quoted string) to int64 Unix seconds.
func parseTimestampNumber(n json.Number) int64 {
s := n.String()
Expand Down
21 changes: 21 additions & 0 deletions fundamental/jsontypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,3 +586,24 @@ type RatingLeafIndicator struct {
Score json.RawMessage `json:"score"`
Letter string `json:"letter"`
}

// ── 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"`
}
24 changes: 24 additions & 0 deletions fundamental/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -977,3 +977,27 @@ type RatingLeafIndicator struct {
// Letter grade.
Letter 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
}
58 changes: 58 additions & 0 deletions market/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package market

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -262,6 +263,63 @@ 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 json.RawMessage
if err := m.httpClient.Post(ctx, "/v1/quote/market/stock-events", body, &resp); err != nil {
return nil, err
}
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 json.RawMessage
if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/categories", url.Values{}, &resp); err != nil {
return nil, err
}
return &RankCategoriesResponse{Data: resp}, 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 json.RawMessage
if err := m.httpClient.Get(ctx, "/v1/quote/market/rank/list", params, &resp); err != nil {
return nil, err
}
return &RankListResponse{Data: resp}, nil
}

// --- helpers ---

// toAPIString converts a BrokerHoldingPeriod to the API's type parameter value.
Expand Down
19 changes: 19 additions & 0 deletions market/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package market

import (
"encoding/json"
"time"

"github.com/shopspring/decimal"
Expand Down Expand Up @@ -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 `json:"data"`
}

// RankCategoriesResponse holds the raw data for rank categories from
// GET /v1/quote/market/rank/categories.
type RankCategoriesResponse struct {
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 `json:"data"`
}
70 changes: 47 additions & 23 deletions quote/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package quote

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -523,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.
Expand Down Expand Up @@ -675,6 +674,31 @@ func New(opt ...Option) (*QuoteContext, error) {
return tc, 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 {
Expand Down
20 changes: 12 additions & 8 deletions quote/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package quote

import (
"encoding/json"
"time"

quotev1 "github.com/longbridge/openapi-protobufs/gen/go/quote"
Expand Down Expand Up @@ -725,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
Expand Down Expand Up @@ -776,3 +774,9 @@ func CandlestickRequestTradeSession(session CandlestickTradeSession) Candlestick
req.TradeSession = int32(session)
}
}

// 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
}
Loading