diff --git a/CHANGELOG.md b/CHANGELOG.md index 033f331..8940afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,48 @@ ## [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: - - `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 `/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. + - `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/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 6da63f9..b1f31de 100644 --- a/fundamental/context.go +++ b/fundamental/context.go @@ -1109,6 +1109,141 @@ 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 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 + } + 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. 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..8ea8c96 100644 --- a/fundamental/types.go +++ b/fundamental/types.go @@ -1231,3 +1231,55 @@ 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 ─────────────────────────────────── + +// 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 { + List []*ValuationComparisonItem +} diff --git a/market/context.go b/market/context.go index 7583674..ca7ded1 100644 --- a/market/context.go +++ b/market/context.go @@ -5,8 +5,10 @@ package market import ( "context" + "encoding/json" "fmt" "net/url" + "strconv" "strings" "time" @@ -262,6 +264,126 @@ func (m *MarketContext) Constituent(ctx context.Context, symbol string) (*IndexC return out, nil } +// 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 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, + "markets": markets, + } + if date != "" { + body["date"] = date + } + 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 + } + 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. +// +// 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 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 + } + 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 --- // toAPIString converts a BrokerHoldingPeriod to the API's type parameter value. @@ -294,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:] @@ -304,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 474b7cc..3e810f5 100644 --- a/market/types.go +++ b/market/types.go @@ -1,11 +1,61 @@ package market import ( + "encoding/json" "time" "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 @@ -315,3 +365,22 @@ type ConstituentStock struct { // Raw trade status code TradeStatus int32 } + +// TopMoversResponse is the response for MarketContext.TopMovers. +type TopMoversResponse struct { + 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 +// GET /v1/quote/market/rank/categories. +type RankCategoriesResponse struct { + Data json.RawMessage `json:"data"` +} + +// RankListResponse is the response for MarketContext.RankList. +type RankListResponse struct { + Bmp bool + Lists []*RankListItem +} diff --git a/quote/context.go b/quote/context.go index 7024868..3ff8687 100644 --- a/quote/context.go +++ b/quote/context.go @@ -2,8 +2,10 @@ package quote import ( "context" + "encoding/json" "fmt" "net/url" + "strconv" "strings" "time" @@ -523,33 +525,46 @@ 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). +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" + } 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 { - return nil, err + values.Set("last_timestamp", fmt.Sprintf("%d", time.Now().Unix())) + values.Set("count", fmt.Sprintf("%d", count)) + // Response: {"counter_id": "ST/US/AAPL", "data": [{...}]} + var outer struct { + CounterID string `json:"counter_id"` + Data []map[string]json.RawMessage `json:"data"` } - stats := &ShortPositionStats{ - Symbol: resp.Symbol, - Sources: resp.Sources, + if err := c.opts.httpClient.Get(ctx, path, values, &outer); err != nil { + return nil, err } - 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, + 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"), + 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 stats, nil + return &ShortPositionsResponse{Data: items}, nil } // OptionVolume returns aggregated call/put volume stats for a security. @@ -675,6 +690,46 @@ 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) { + 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" + } + // 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(outer.Data)) + for _, r := range outer.Data { + 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 // that require the internal counter_id format. func quoteSymbolToCounterID(symbol string) string { @@ -684,3 +739,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 9922cf6..0082a16 100644 --- a/quote/types.go +++ b/quote/types.go @@ -709,30 +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 } -// 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 is the response for QuoteContext.ShortPositions. +type ShortPositionsResponse struct { + Data []*ShortPositionsItem } // OptionVolumeStats contains aggregated call/put volume for a security @@ -776,3 +780,28 @@ func CandlestickRequestTradeSession(session CandlestickTradeSession) Candlestick req.TradeSession = int32(session) } } + +// 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 []*ShortTradesItem +} diff --git a/screener/context.go b/screener/context.go new file mode 100644 index 0000000..ce0990c --- /dev/null +++ b/screener/context.go @@ -0,0 +1,137 @@ +// 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 json.RawMessage + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/recommend", url.Values{}, &resp); err != nil { + return nil, err + } + return &RecommendStrategiesResponse{Data: resp}, 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 json.RawMessage + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategies/mine", url.Values{}, &resp); err != nil { + return nil, err + } + return &UserStrategiesResponse{Data: resp}, 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 json.RawMessage + if err := c.httpClient.Get(ctx, "/v1/quote/screener/strategy", q, &resp); err != nil { + return nil, err + } + return &StrategyResponse{Data: resp}, 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 json.RawMessage + if err := c.httpClient.Post(ctx, "/v1/quote/screener/search", body, &resp); err != nil { + return nil, err + } + return &ScreenerSearchResponse{Data: resp}, 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 json.RawMessage + if err := c.httpClient.Get(ctx, "/v1/quote/screener/indicators", url.Values{}, &resp); err != nil { + return nil, err + } + return &ScreenerIndicatorsResponse{Data: resp}, nil +} diff --git a/screener/types.go b/screener/types.go new file mode 100644 index 0000000..dba28a8 --- /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 `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 `json:"data"` +} + +// StrategyResponse holds the raw data for a single screener strategy from +// GET /v1/quote/screener/strategy. +type StrategyResponse struct { + Data json.RawMessage `json:"data"` +} + +// ScreenerSearchResponse holds the raw search results from +// POST /v1/quote/screener/search. +type ScreenerSearchResponse struct { + 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 `json:"data"` +}