feat: add Citations and WatchlistChat components, integrate with API
- Implemented Citations component to display citation data. - Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality. - Integrated API calls for watchlist management and chat interactions. - Updated index.tsx to include new components in the main application layout. - Added API client in lib/api.ts for structured API interactions. - Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components. - Introduced styles in tokens.css for consistent theming across the application. - Configured TypeScript and Vite for project setup and development.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
// Package sectors wraps the Sectors API v2 with retry, credit counting, and
|
||||
// parameter-narrowing guards. Every outbound Sectors call in FlowSight goes
|
||||
// through SectorsClient — no raw HTTP to api.sectors.app elsewhere.
|
||||
//
|
||||
// Auth: `Authorization: <raw-key>` from env SECTORS_API_KEY. v1 is gone (410);
|
||||
// only v2 paths are used.
|
||||
package sectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoAPIKey is returned when no SECTORS_API_KEY is configured (offline mode).
|
||||
var ErrNoAPIKey = errors.New("sectors: SECTORS_API_KEY not set (offline/seed mode)")
|
||||
|
||||
// ErrV1Gone is returned if a v1 path is ever requested (v1 returns 410).
|
||||
var ErrV1Gone = errors.New("sectors: v1 API discontinued (410 Gone), use v2 only")
|
||||
|
||||
// Spend tracks credit usage per endpoint for one day.
|
||||
type Spend struct {
|
||||
Calls int
|
||||
Credits int
|
||||
}
|
||||
|
||||
// Client is a credit-counting Sectors v2 HTTP client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
day string
|
||||
spend map[string]*Spend
|
||||
onSpend func(endpoint string, calls, credits int)
|
||||
}
|
||||
|
||||
// New builds a client. baseURL should end with a slash.
|
||||
func New(baseURL, apiKey string) *Client {
|
||||
if !strings.HasSuffix(baseURL, "/") {
|
||||
baseURL += "/"
|
||||
}
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Transport: tr, Timeout: 30 * time.Second},
|
||||
day: time.Now().Format("2006-01-02"),
|
||||
spend: map[string]*Spend{},
|
||||
}
|
||||
}
|
||||
|
||||
// OnSpend registers a callback fired after every counted call (used by the
|
||||
// scheduler to persist rows into credit_ledger).
|
||||
func (c *Client) OnSpend(fn func(endpoint string, calls, credits int)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.onSpend = fn
|
||||
}
|
||||
|
||||
// SpendToday returns a copy of today's per-endpoint spend.
|
||||
func (c *Client) SpendToday() map[string]Spend {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make(map[string]Spend, len(c.spend))
|
||||
for k, v := range c.spend {
|
||||
out[k] = *v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreditsToday returns total credits spent today.
|
||||
func (c *Client) CreditsToday() int {
|
||||
total := 0
|
||||
for _, s := range c.SpendToday() {
|
||||
total += s.Credits
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (c *Client) count(endpoint string, calls, credits int) {
|
||||
c.mu.Lock()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if today != c.day {
|
||||
c.day, c.spend = today, map[string]*Spend{}
|
||||
}
|
||||
s := c.spend[endpoint]
|
||||
if s == nil {
|
||||
s = &Spend{}
|
||||
c.spend[endpoint] = s
|
||||
}
|
||||
s.Calls += calls
|
||||
s.Credits += credits
|
||||
fn := c.onSpend
|
||||
c.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(endpoint, calls, credits)
|
||||
}
|
||||
}
|
||||
|
||||
// costFor implements the credit-cost table from docs/API-REFERENCE.md.
|
||||
func costFor(path string, q url.Values) int {
|
||||
p := strings.Trim(path, "/")
|
||||
switch {
|
||||
case p == "v2/companies/top-changes" || p == "companies/top-changes":
|
||||
classes := splitCSV(q.Get("classifications"))
|
||||
periods := splitCSV(q.Get("periods"))
|
||||
if len(classes) == 0 {
|
||||
classes = []string{"a", "b"} // default is expensive: 2 classes
|
||||
}
|
||||
if len(periods) == 0 {
|
||||
periods = []string{"a", "b", "c", "d", "e"} // default: 5 periods
|
||||
}
|
||||
return len(classes) * len(periods)
|
||||
case strings.HasPrefix(p, "v2/company/report") || strings.HasPrefix(p, "company/report"):
|
||||
if s := splitCSV(q.Get("sections")); len(s) > 0 {
|
||||
return len(s)
|
||||
}
|
||||
return 8 // default all sections
|
||||
case strings.HasPrefix(p, "v2/subsector/report") || strings.HasPrefix(p, "subsector/report"):
|
||||
if s := splitCSV(q.Get("sections")); len(s) > 0 {
|
||||
return len(s)
|
||||
}
|
||||
return 6
|
||||
case strings.HasPrefix(p, "v2/financials/quarterly") || strings.HasPrefix(p, "financials/quarterly"):
|
||||
if n := atoi(q.Get("n_quarters"), 1); n > 0 {
|
||||
return n
|
||||
}
|
||||
return 1
|
||||
case p == "v2/most-traded" || p == "most-traded",
|
||||
p == "v2/brokers/top" || p == "brokers/top",
|
||||
strings.HasSuffix(p, "/top") && strings.Contains(p, "broker-activity"),
|
||||
strings.HasSuffix(p, "/top") && strings.Contains(p, "broker-summary"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func atoi(s string, def int) int {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// maxBrokerWindowDays and maxSeriesDays encode the API window limits.
|
||||
const (
|
||||
maxBrokerWindowDays = 14
|
||||
maxSeriesDays = 90
|
||||
)
|
||||
|
||||
// ClampBrokerWindow clamps a date range to the 14-day broker-endpoint limit.
|
||||
func ClampBrokerWindow(start, end time.Time) (time.Time, time.Time) {
|
||||
if end.Sub(start).Hours()/24 > maxBrokerWindowDays {
|
||||
start = end.AddDate(0, 0, -maxBrokerWindowDays)
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// ClampSeriesWindow clamps a date range to the 90-day series limit.
|
||||
func ClampSeriesWindow(start, end time.Time) (time.Time, time.Time) {
|
||||
if end.Sub(start).Hours()/24 > maxSeriesDays {
|
||||
start = end.AddDate(0, 0, -maxSeriesDays)
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func isoDate(t time.Time) string { return t.Format("2006-01-02") }
|
||||
|
||||
// Get performs a GET against a v2 path, enforcing auth, retry, and credit
|
||||
// counting. Callers pass paths like "v2/brokers/top/" or "brokers/top/".
|
||||
func (c *Client) Get(ctx context.Context, path string, q url.Values) ([]byte, error) {
|
||||
if strings.TrimSpace(c.apiKey) == "" {
|
||||
return nil, ErrNoAPIKey
|
||||
}
|
||||
if strings.Contains(path, "/v1/") || strings.HasPrefix(path, "v1/") {
|
||||
return nil, ErrV1Gone
|
||||
}
|
||||
if e := q.Get("end"); e != "" {
|
||||
if t, err := time.Parse("2006-01-02", e); err == nil && t.After(time.Now().Add(24*time.Hour)) {
|
||||
return nil, fmt.Errorf("sectors: future end date %s rejected (API returns 400)", e)
|
||||
}
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(path, "/"), "v2/")
|
||||
endpoint := "v2/" + strings.Trim(rel, "/") + "/"
|
||||
credits := costFor(path, q)
|
||||
|
||||
full := c.baseURL + rel
|
||||
if len(q) > 0 {
|
||||
full += "?" + q.Encode()
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt*attempt) * 500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
c.count(endpoint, 1, credits)
|
||||
return body, nil
|
||||
case resp.StatusCode == http.StatusGone:
|
||||
return nil, ErrV1Gone
|
||||
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("sectors: %s -> HTTP %d (retrying)", endpoint, resp.StatusCode)
|
||||
continue
|
||||
default:
|
||||
c.count(endpoint, 1, credits)
|
||||
return nil, fmt.Errorf("sectors: %s -> HTTP %d: %s", endpoint, resp.StatusCode, truncate(string(body), 300))
|
||||
}
|
||||
}
|
||||
c.count(endpoint, 1, credits)
|
||||
return nil, fmt.Errorf("sectors: %s failed after retries: %w", endpoint, lastErr)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// EstimateCycleCredits returns the expected credit spend of one 30-min
|
||||
// ingestion cycle for W watchlist tickers (docs/API-REFERENCE.md §budget).
|
||||
func EstimateCycleCredits(w int) int {
|
||||
return 10 + 2 + 2 + 1 + 2 + 3*w + 3 + 2 // ~= 22 + 3W
|
||||
}
|
||||
|
||||
// MorningBriefingExtra returns (min, max) extra credits for the briefing run.
|
||||
func MorningBriefingExtra() (int, int) { return 35, 40 }
|
||||
@@ -0,0 +1,129 @@
|
||||
package sectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One live-shape call against a mock upstream: narrowing is enforced and
|
||||
// credits counted per the API-REFERENCE cost table.
|
||||
func TestNarrowingAndCredits(t *testing.T) {
|
||||
var gotQ url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQ = r.URL.Query()
|
||||
w.Write([]byte(`{"top_gainers":{"1d":[]},"top_losers":{"1d":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL+"/", "test-key")
|
||||
_, _, err := c.TopChanges(context.Background(),
|
||||
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotQ.Get("classifications") != "top_gainers" {
|
||||
t.Fatalf("classifications = %q, want narrowed", gotQ.Get("classifications"))
|
||||
}
|
||||
if gotQ.Get("periods") != "1d,7d" {
|
||||
t.Fatalf("periods = %q, want narrowed", gotQ.Get("periods"))
|
||||
}
|
||||
if got := c.CreditsToday(); got != 2 {
|
||||
t.Fatalf("credits = %d, want 2 (1 class x 2 periods)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-cycle budget: EstimateCycleCredits(20) must stay <= 120 cap.
|
||||
func TestCycleBudget(t *testing.T) {
|
||||
if got := EstimateCycleCredits(20); got > 120 {
|
||||
t.Fatalf("cycle estimate = %d, exceeds 120 cap", got)
|
||||
}
|
||||
if got := EstimateCycleCredits(5); got != 22+3*5 {
|
||||
t.Fatalf("cycle estimate = %d, want %d", got, 22+3*5)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 paths are rejected before any HTTP happens.
|
||||
func TestV1Rejected(t *testing.T) {
|
||||
c := New("https://api.sectors.app/v2/", "k")
|
||||
if _, err := c.Get(context.Background(), "v1/companies/", url.Values{}); err != ErrV1Gone {
|
||||
t.Fatalf("err = %v, want ErrV1Gone", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Future end dates are rejected (API returns 400).
|
||||
func TestFutureEndRejected(t *testing.T) {
|
||||
c := New("https://api.sectors.app/v2/", "k")
|
||||
q := url.Values{"end": {"2099-01-01"}}
|
||||
if _, err := c.Get(context.Background(), "daily/BBCA/", q); err == nil {
|
||||
t.Fatal("want future-end rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// New wrappers hit exact paths with exact params; all cost 1 credit.
|
||||
func TestCompanyDepthWrappers(t *testing.T) {
|
||||
var gotPath, gotQ string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotQ = r.URL.Path, r.URL.RawQuery
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/company/get-segments/"):
|
||||
w.Write([]byte(`{"symbol":"BBCA.JK","financial_year":2025,"revenue_breakdown":[{"value":1,"source":"Loans","target":"Interest Income"}]}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/company/get_quarterly_financial_dates/"):
|
||||
w.Write([]byte(`{"2026":[["2026-03-31","q1"]]}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/listing-performance/"):
|
||||
w.Write([]byte(`{"symbol":"BBCA.JK","listing_date":"2000-05-31"}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/index-daily/"):
|
||||
w.Write([]byte(`[{"index_code":"ihsg","date":"2026-09-11","price":7800.5}]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/company/shareholders-composition/"):
|
||||
w.Write([]byte(`{"symbol":"BBCA.JK","year":2026,"data":[]}`))
|
||||
default:
|
||||
w.Write([]byte(`{"BBCA.JK":{"financial_year":[2025]}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL+"/", "test-key")
|
||||
if _, err := c.Segments(context.Background(), "BBCA", 2025); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotQ != "financial_year=2025" {
|
||||
t.Fatalf("segments query = %q", gotQ)
|
||||
}
|
||||
qd, err := c.QuarterlyDates(context.Background(), "BBCA")
|
||||
if err != nil || len(qd) != 1 || qd[0].ReportDate != "2026-03-31" {
|
||||
t.Fatalf("quarterly dates = %v, %v", qd, err)
|
||||
}
|
||||
if _, err := c.ListingPerformance(context.Background(), "BBCA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.IndexDaily(context.Background(), "ihsg", "2026-09-01", "2026-09-11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Shareholders(context.Background(), "BBCA", 2026); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avail, err := c.SegmentAvailability(context.Background())
|
||||
if err != nil || len(avail["BBCA.JK"]) != 1 {
|
||||
t.Fatalf("availability = %v, %v", avail, err)
|
||||
}
|
||||
_ = gotPath
|
||||
}
|
||||
|
||||
// Event windows clamp to 90d and report real costs.
|
||||
func TestEventClamps(t *testing.T) {
|
||||
var gotQ url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQ = r.URL.Query()
|
||||
w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL+"/", "test-key")
|
||||
_, _ = c.News(context.Background(), "BBCA", "2026-01-01", "2026-09-11", "", 20)
|
||||
if gotQ.Get("start") != "2026-06-13" {
|
||||
t.Fatalf("news start = %q, want clamped to 90d", gotQ.Get("start"))
|
||||
}
|
||||
if got := c.CreditsToday(); got < 1 {
|
||||
t.Fatalf("credits = %d, want counted", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package sectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// param helpers keep every wrapper narrow by default (credit traps in
|
||||
// docs/API-REFERENCE.md): sections explicit, classifications+periods minimal,
|
||||
// n_quarters bounded, free-float filters exclusive.
|
||||
|
||||
func dateRange(start, end string, clamp func(time.Time, time.Time) (time.Time, time.Time)) (string, string) {
|
||||
if start == "" || end == "" {
|
||||
e := time.Now()
|
||||
s := e.AddDate(0, 0, -7)
|
||||
if start == "" && end != "" {
|
||||
if t, err := time.Parse("2006-01-02", end); err == nil {
|
||||
e, s = t, t.AddDate(0, 0, -7)
|
||||
}
|
||||
}
|
||||
return s.Format("2006-01-02"), e.Format("2006-01-02")
|
||||
}
|
||||
ts, err1 := time.Parse("2006-01-02", start)
|
||||
te, err2 := time.Parse("2006-01-02", end)
|
||||
if err1 != nil || err2 != nil {
|
||||
return start, end
|
||||
}
|
||||
ts, te = clamp(ts, te)
|
||||
return ts.Format("2006-01-02"), te.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func decode(data []byte, v any, endpoint string) error {
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("sectors: decode %s: %w", endpoint, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Brokers: the moat ---
|
||||
|
||||
// BrokersRegistry returns the broker registry (cached daily by the scheduler).
|
||||
func (c *Client) BrokersRegistry(ctx context.Context, cohort, origin string) ([]BrokerRegistryRow, error) {
|
||||
q := url.Values{}
|
||||
if cohort != "" && cohort != "all" {
|
||||
q.Set("cohort", cohort)
|
||||
}
|
||||
if origin != "" && origin != "all" {
|
||||
q.Set("origin", origin)
|
||||
}
|
||||
raw, err := c.Get(ctx, "brokers/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []BrokerRegistryRow
|
||||
if err := decode(raw, &rows, "v2/brokers/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// BrokersTop returns the daily broker ranking.
|
||||
func (c *Client) BrokersTop(ctx context.Context, date, metric string, n int, origin, cohort string) ([]TopBrokerRow, error) {
|
||||
q := url.Values{}
|
||||
if date != "" {
|
||||
q.Set("date", date)
|
||||
}
|
||||
if metric == "" {
|
||||
metric = "net"
|
||||
}
|
||||
q.Set("metric", metric)
|
||||
if n <= 0 || n > 90 {
|
||||
n = 20
|
||||
}
|
||||
q.Set("n_brokers", fmt.Sprint(n))
|
||||
if origin != "" {
|
||||
q.Set("origin", origin)
|
||||
}
|
||||
if cohort != "" {
|
||||
q.Set("cohort", cohort)
|
||||
}
|
||||
raw, err := c.Get(ctx, "brokers/top/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []TopBrokerRow `json:"results"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/brokers/top/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Results, nil
|
||||
}
|
||||
|
||||
// BrokerActivity returns all (stock,day) rows for one broker (window ≤14d).
|
||||
func (c *Client) BrokerActivity(ctx context.Context, brokerCode, symbol, start, end string) ([]BrokerRow, error) {
|
||||
start, end = dateRange(start, end, ClampBrokerWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
if symbol != "" {
|
||||
q.Set("symbol", strings.ToUpper(symbol))
|
||||
}
|
||||
raw, err := c.Get(ctx, "broker-activity/"+strings.ToUpper(brokerCode)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []BrokerRow
|
||||
if err := decode(raw, &rows, "v2/broker-activity/{broker_code}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// BrokerActivityTop returns top accumulations/distributions for one broker.
|
||||
func (c *Client) BrokerActivityTop(ctx context.Context, brokerCode, start, end string, n int) (accum, distrib []BrokerRow, err error) {
|
||||
start, end = dateRange(start, end, ClampBrokerWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
if n > 0 {
|
||||
q.Set("n_brokers", fmt.Sprint(n))
|
||||
}
|
||||
raw, err := c.Get(ctx, "broker-activity/"+strings.ToUpper(brokerCode)+"/top/", q)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var resp struct {
|
||||
TopAccumulations []BrokerRow `json:"top_accumulations"`
|
||||
TopDistributions []BrokerRow `json:"top_distributions"`
|
||||
TopAccumulation []BrokerRow `json:"top_accumulation"`
|
||||
TopDistribution []BrokerRow `json:"top_distribution"`
|
||||
Accumulations []BrokerRow `json:"accumulations"`
|
||||
Distributions []BrokerRow `json:"distributions"`
|
||||
Data []BrokerRow `json:"data"`
|
||||
Results []BrokerRow `json:"results"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/broker-activity/{code}/top/"); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
accum = append(append(append(resp.TopAccumulations, resp.TopAccumulation...), resp.Accumulations...), resp.Data...)
|
||||
distrib = append(append(resp.TopDistributions, resp.TopDistribution...), resp.Distributions...)
|
||||
if len(accum) == 0 && len(resp.Results) > 0 {
|
||||
accum = resp.Results
|
||||
}
|
||||
return accum, distrib, nil
|
||||
}
|
||||
|
||||
// BrokerSummary returns per-broker daily rows for one ticker (window ≤14d).
|
||||
func (c *Client) BrokerSummary(ctx context.Context, symbol, brokerCode, start, end string) ([]BrokerRow, error) {
|
||||
start, end = dateRange(start, end, ClampBrokerWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
if brokerCode != "" {
|
||||
q.Set("broker_code", strings.ToUpper(brokerCode))
|
||||
}
|
||||
raw, err := c.Get(ctx, "broker-summary/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Shape varies (rows array or {dates: {broker: rows}}); normalize both.
|
||||
var rows []BrokerRow
|
||||
if err := json.Unmarshal(raw, &rows); err == nil {
|
||||
return rows, nil
|
||||
}
|
||||
var byDate map[string]map[string][]BrokerRow
|
||||
if err := json.Unmarshal(raw, &byDate); err == nil {
|
||||
for _, brokers := range byDate {
|
||||
for _, r := range brokers {
|
||||
rows = append(rows, r...)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
var wrapped struct {
|
||||
Data []BrokerRow `json:"data"`
|
||||
Results []BrokerRow `json:"results"`
|
||||
}
|
||||
if err := decode(raw, &wrapped, "v2/broker-summary/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(wrapped.Data, wrapped.Results...), nil
|
||||
}
|
||||
|
||||
// BrokerSummaryTop returns top buyers/sellers for one ticker (2 credits).
|
||||
func (c *Client) BrokerSummaryTop(ctx context.Context, symbol, start, end string, n int, origin, cohort string) (*BrokerSummaryTop, error) {
|
||||
start, end = dateRange(start, end, ClampBrokerWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
if n > 0 {
|
||||
q.Set("n_brokers", fmt.Sprint(n))
|
||||
}
|
||||
if origin != "" {
|
||||
q.Set("origin", origin)
|
||||
}
|
||||
if cohort != "" {
|
||||
q.Set("cohort", cohort)
|
||||
}
|
||||
raw, err := c.Get(ctx, "broker-summary/"+strings.ToUpper(symbol)+"/top/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp BrokerSummaryTop
|
||||
if err := decode(raw, &resp, "v2/broker-summary/{symbol}/top/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ForeignFlow returns the daily net foreign inflow series (window ≤90d).
|
||||
func (c *Client) ForeignFlow(ctx context.Context, symbol, start, end string) (*ForeignFlow, error) {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
raw, err := c.Get(ctx, "foreign-flow/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp ForeignFlow
|
||||
if err := decode(raw, &resp, "v2/foreign-flow/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// --- Company core ---
|
||||
|
||||
// CompanyReport fetches explicit sections only (1 credit/section).
|
||||
func (c *Client) CompanyReport(ctx context.Context, symbol string, sections []string) (map[string]any, error) {
|
||||
if len(sections) == 0 {
|
||||
sections = []string{"overview", "valuation"}
|
||||
}
|
||||
q := url.Values{"sections": {strings.Join(sections, ",")}}
|
||||
raw, err := c.Get(ctx, "company/report/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]any
|
||||
if err := decode(raw, &out, "v2/company/report/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Quarterly fetches up to n financial quarters (1 credit/quarter, n≤8).
|
||||
func (c *Client) Quarterly(ctx context.Context, symbol string, n int) ([]QuarterRow, error) {
|
||||
if n <= 0 {
|
||||
n = 4
|
||||
}
|
||||
if n > 8 {
|
||||
n = 8
|
||||
}
|
||||
q := url.Values{"n_quarters": {fmt.Sprint(n)}}
|
||||
raw, err := c.Get(ctx, "financials/quarterly/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []QuarterRow
|
||||
if err := decode(raw, &rows, "v2/financials/quarterly/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// CorporateActions fetches splits/rights/warrants/AGM/dividends for one ticker.
|
||||
func (c *Client) CorporateActions(ctx context.Context, symbol string) (*CorporateActions, error) {
|
||||
raw, err := c.Get(ctx, "company/corporate-actions/"+strings.ToUpper(symbol)+"/", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CorporateActions struct {
|
||||
Dividend []DividendEvent `json:"dividend"`
|
||||
UpcomingDividend []DividendEvent `json:"upcoming_dividend"`
|
||||
AGM []DividendEvent `json:"agm"`
|
||||
StockSplit []DividendEvent `json:"stock_split"`
|
||||
} `json:"corporate_actions"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/company/corporate-actions/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CorporateActions{
|
||||
Symbol: resp.Symbol,
|
||||
Dividend: resp.CorporateActions.Dividend,
|
||||
UpcomingDividend: resp.CorporateActions.UpcomingDividend,
|
||||
AGM: resp.CorporateActions.AGM,
|
||||
StockSplit: resp.CorporateActions.StockSplit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Universe / market ---
|
||||
|
||||
// ClosePage fetches one page of the full-universe close sweep.
|
||||
func (c *Client) ClosePage(ctx context.Context, date string, limit, offset int) ([]CloseRow, int, error) {
|
||||
q := url.Values{}
|
||||
if date != "" {
|
||||
q.Set("date", date)
|
||||
}
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 30
|
||||
}
|
||||
q.Set("limit", fmt.Sprint(limit))
|
||||
q.Set("offset", fmt.Sprint(offset))
|
||||
raw, err := c.Get(ctx, "close/", q)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []CloseRow `json:"results"`
|
||||
Pagination struct {
|
||||
Total int `json:"total"`
|
||||
Count int `json:"count"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/close/"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return resp.Results, resp.Pagination.Total, nil
|
||||
}
|
||||
|
||||
// QuarterlyDatesSince polls newly-reported companies incrementally.
|
||||
func (c *Client) QuarterlyDatesSince(ctx context.Context, since string, limit, offset int) ([]QuarterlyDateRow, error) {
|
||||
q := url.Values{}
|
||||
if since != "" {
|
||||
q.Set("since", since)
|
||||
}
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 30
|
||||
}
|
||||
q.Set("limit", fmt.Sprint(limit))
|
||||
q.Set("offset", fmt.Sprint(offset))
|
||||
raw, err := c.Get(ctx, "companies/quarterly-financial-dates/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []QuarterlyDateRow `json:"results"`
|
||||
Data []QuarterlyDateRow `json:"data"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/companies/quarterly-financial-dates/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resp.Results, resp.Data...), nil
|
||||
}
|
||||
|
||||
// Daily returns the price+volume+MCap series for one ticker (window ≤90d).
|
||||
func (c *Client) Daily(ctx context.Context, symbol, start, end string) ([]DailyBar, error) {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
raw, err := c.Get(ctx, "daily/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []DailyBar
|
||||
if err := decode(raw, &rows, "v2/daily/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// IdxTotal returns the IHSG total-MCap trend (macro context).
|
||||
func (c *Client) IdxTotal(ctx context.Context, start, end string) ([]IdxTotalPoint, error) {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
raw, err := c.Get(ctx, "idx-total/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []IdxTotalPoint
|
||||
if err := decode(raw, &rows, "v2/idx-total/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// TopChanges requests minimal class×period combos only (1 credit per combo).
|
||||
func (c *Client) TopChanges(ctx context.Context, classifications, periods []string, subSector string, n int) (gainers, losers map[string][]MoverRow, err error) {
|
||||
if len(classifications) == 0 {
|
||||
classifications = []string{"top_gainers"}
|
||||
}
|
||||
if len(periods) == 0 {
|
||||
periods = []string{"1d"}
|
||||
}
|
||||
q := url.Values{
|
||||
"classifications": {strings.Join(classifications, ",")},
|
||||
"periods": {strings.Join(periods, ",")},
|
||||
}
|
||||
if subSector != "" {
|
||||
q.Set("sub_sector", subSector)
|
||||
}
|
||||
if n > 0 {
|
||||
q.Set("n_stock", fmt.Sprint(n))
|
||||
}
|
||||
raw, err := c.Get(ctx, "companies/top-changes/", q)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var resp struct {
|
||||
TopGainers map[string][]MoverRow `json:"top_gainers"`
|
||||
TopLosers map[string][]MoverRow `json:"top_losers"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/companies/top-changes/"); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resp.TopGainers, resp.TopLosers, nil
|
||||
}
|
||||
|
||||
// MostTraded returns relative volume leaders.
|
||||
func (c *Client) MostTraded(ctx context.Context, start, end, subSector string, n int) ([]map[string]any, error) {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
if subSector != "" {
|
||||
q.Set("sub_sector", subSector)
|
||||
}
|
||||
if n > 0 {
|
||||
q.Set("n_stock", fmt.Sprint(n))
|
||||
}
|
||||
raw, err := c.Get(ctx, "most-traded/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []map[string]any `json:"results"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/most-traded/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resp.Results, resp.Data...), nil
|
||||
}
|
||||
|
||||
// --- Events (incremental) ---
|
||||
|
||||
// News fetches articles incrementally (extension idx/mining exclusive).
|
||||
// Windows clamp to 90d like other series endpoints.
|
||||
func (c *Client) News(ctx context.Context, symbols, start, end, keyword string, limit int) ([]NewsArticle, error) {
|
||||
if start != "" || end != "" {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
}
|
||||
q := url.Values{"extension": {"idx"}}
|
||||
if symbols != "" {
|
||||
q.Set("symbols", symbols)
|
||||
}
|
||||
if start != "" {
|
||||
q.Set("start", start)
|
||||
}
|
||||
if end != "" {
|
||||
q.Set("end", end)
|
||||
}
|
||||
if keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprint(limit))
|
||||
}
|
||||
raw, err := c.Get(ctx, "news/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []NewsArticle `json:"results"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/news/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Results, nil
|
||||
}
|
||||
|
||||
// Filings fetches insider/institution transactions incrementally.
|
||||
// Windows clamp to 90d like other series endpoints.
|
||||
func (c *Client) Filings(ctx context.Context, symbol, txnType, holderType, start, end string) ([]Filing, error) {
|
||||
if start != "" || end != "" {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
}
|
||||
q := url.Values{}
|
||||
if symbol != "" {
|
||||
q.Set("symbol", strings.ToUpper(symbol))
|
||||
}
|
||||
if txnType != "" {
|
||||
q.Set("transaction_type", txnType)
|
||||
}
|
||||
if holderType != "" {
|
||||
q.Set("holder_type", holderType)
|
||||
}
|
||||
if start != "" {
|
||||
q.Set("start", start)
|
||||
}
|
||||
if end != "" {
|
||||
q.Set("end", end)
|
||||
}
|
||||
raw, err := c.Get(ctx, "filings/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []Filing `json:"results"`
|
||||
Data []Filing `json:"data"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/filings/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resp.Results, resp.Data...), nil
|
||||
}
|
||||
|
||||
// Suspensions fetches new IDX suspension notices.
|
||||
// Windows clamp to 90d like other series endpoints.
|
||||
func (c *Client) Suspensions(ctx context.Context, symbol, start, end string) ([]Suspension, error) {
|
||||
if start != "" || end != "" {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
}
|
||||
q := url.Values{}
|
||||
if symbol != "" {
|
||||
q.Set("symbol", strings.ToUpper(symbol))
|
||||
}
|
||||
if start != "" {
|
||||
q.Set("start", start)
|
||||
}
|
||||
if end != "" {
|
||||
q.Set("end", end)
|
||||
}
|
||||
raw, err := c.Get(ctx, "suspensions/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []Suspension `json:"results"`
|
||||
Data []Suspension `json:"data"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/suspensions/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resp.Results, resp.Data...), nil
|
||||
}
|
||||
|
||||
// --- Screener / taxonomy ---
|
||||
|
||||
// Screen runs the companies/ structured screener (q overrides all).
|
||||
func (c *Client) Screen(ctx context.Context, where, q string, limit, offset int) ([]ScreenerRow, error) {
|
||||
qq := url.Values{}
|
||||
if q != "" {
|
||||
qq.Set("q", q)
|
||||
} else if where != "" {
|
||||
qq.Set("where", where)
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
qq.Set("limit", fmt.Sprint(limit))
|
||||
qq.Set("offset", fmt.Sprint(offset))
|
||||
raw, err := c.Get(ctx, "companies/", qq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Results []ScreenerRow `json:"results"`
|
||||
Data []ScreenerRow `json:"data"`
|
||||
}
|
||||
if err := decode(raw, &resp, "v2/companies/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resp.Results, resp.Data...), nil
|
||||
}
|
||||
|
||||
// FreeFloat fetches one exclusive filter per request (API rejects mixed).
|
||||
func (c *Client) FreeFloat(ctx context.Context, sector, subSector, industry, subIndustry string) ([]FreeFloatRow, error) {
|
||||
q := url.Values{}
|
||||
set := 0
|
||||
for k, v := range map[string]string{"sector": sector, "sub_sector": subSector, "industry": industry, "sub_industry": subIndustry} {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
set++
|
||||
}
|
||||
}
|
||||
if set != 1 {
|
||||
return nil, fmt.Errorf("sectors: free-float needs exactly one filter (got %d)", set)
|
||||
}
|
||||
raw, err := c.Get(ctx, "free-float/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []FreeFloatRow
|
||||
if err := decode(raw, &rows, "v2/free-float/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Taxonomy fetches one slug list (subsectors/industries/subindustries/tags).
|
||||
func (c *Client) Taxonomy(ctx context.Context, kind string) ([]map[string]any, error) {
|
||||
raw, err := c.Get(ctx, kind+"/", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := decode(raw, &rows, "v2/"+kind+"/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// SubsectorReport fetches explicit sections only (1 credit/section).
|
||||
func (c *Client) SubsectorReport(ctx context.Context, subSector string, sections []string) (map[string]any, error) {
|
||||
if len(sections) == 0 {
|
||||
sections = []string{"statistics"}
|
||||
}
|
||||
q := url.Values{"sections": {strings.Join(sections, ",")}}
|
||||
raw, err := c.Get(ctx, "subsector/report/"+subSector+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]any
|
||||
if err := decode(raw, &out, "v2/subsector/report/{sub_sector}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- Company depth: segments, quarterly dates, IPO, holders, index ---
|
||||
|
||||
// Segments fetches the revenue breakdown (Sankey-ready) for one ticker.
|
||||
func (c *Client) Segments(ctx context.Context, symbol string, year int) (*Segments, error) {
|
||||
q := url.Values{}
|
||||
if year >= 1900 && year <= 2026 {
|
||||
q.Set("financial_year", fmt.Sprint(year))
|
||||
}
|
||||
raw, err := c.Get(ctx, "company/get-segments/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Segments
|
||||
if err := decode(raw, &out, "v2/company/get-segments/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// QuarterlyDates returns valid [report_date, quarter] pairs for one ticker
|
||||
// (use to resolve report_date before Quarterly calls).
|
||||
func (c *Client) QuarterlyDates(ctx context.Context, symbol string) ([]QuarterlyDate, error) {
|
||||
raw, err := c.Get(ctx, "company/get_quarterly_financial_dates/"+strings.ToUpper(symbol)+"/", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Shape: {"YYYY-MM-DD": [["report_date","quarter"], ...], ...} flattened.
|
||||
var grouped map[string][][]string
|
||||
if err := decode(raw, &grouped, "v2/company/get_quarterly_financial_dates/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []QuarterlyDate
|
||||
for _, pairs := range grouped {
|
||||
for _, p := range pairs {
|
||||
if len(p) >= 2 {
|
||||
out = append(out, QuarterlyDate{ReportDate: p[0], Quarter: p[1]})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListingPerformance fetches IPO context (post-May-2005 listings only).
|
||||
func (c *Client) ListingPerformance(ctx context.Context, symbol string) (*ListingPerformance, error) {
|
||||
raw, err := c.Get(ctx, "listing-performance/"+strings.ToUpper(symbol)+"/", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out ListingPerformance
|
||||
if err := decode(raw, &out, "v2/listing-performance/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// IndexDaily returns the benchmark series for beta/correlation (window ≤90d).
|
||||
func (c *Client) IndexDaily(ctx context.Context, indexCode, start, end string) ([]IndexBar, error) {
|
||||
start, end = dateRange(start, end, ClampSeriesWindow)
|
||||
q := url.Values{"start": {start}, "end": {end}}
|
||||
raw, err := c.Get(ctx, "index-daily/"+strings.ToLower(indexCode)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []IndexBar
|
||||
if err := decode(raw, &rows, "v2/index-daily/{index_code}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Shareholders fetches the local-vs-foreign holder mix (data from 2021).
|
||||
func (c *Client) Shareholders(ctx context.Context, symbol string, year int) (*ShareholdersComposition, error) {
|
||||
q := url.Values{}
|
||||
if year >= 2021 {
|
||||
q.Set("year", fmt.Sprint(year))
|
||||
}
|
||||
raw, err := c.Get(ctx, "company/shareholders-composition/"+strings.ToUpper(symbol)+"/", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out ShareholdersComposition
|
||||
if err := decode(raw, &out, "v2/company/shareholders-composition/{symbol}/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SegmentAvailability checks which symbols have segment data (cache weekly).
|
||||
func (c *Client) SegmentAvailability(ctx context.Context) (map[string][]int, error) {
|
||||
raw, err := c.Get(ctx, "companies/list_companies_with_segments/", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]struct {
|
||||
Years []int `json:"financial_year"`
|
||||
}
|
||||
if err := decode(raw, &out, "v2/companies/list_companies_with_segments/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make(map[string][]int, len(out))
|
||||
for k, v := range out {
|
||||
res[k] = v.Years
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package sectors response types mirror docs.sectors.app/schema.json shapes.
|
||||
package sectors
|
||||
|
||||
// BrokerTopRow is one top buyer/seller entry (net/buy/sell in IDR).
|
||||
type BrokerTopRow struct {
|
||||
Rank int `json:"rank"`
|
||||
BrokerCode string `json:"broker_code"`
|
||||
NetIDR int64 `json:"net_idr"`
|
||||
BuyIDR int64 `json:"buy_idr"`
|
||||
SellIDR int64 `json:"sell_idr"`
|
||||
}
|
||||
|
||||
// BrokerSummaryTop is GET broker-summary/{symbol}/top/ response.
|
||||
type BrokerSummaryTop struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Origin string `json:"origin"`
|
||||
Cohort string `json:"cohort"`
|
||||
TopBuyers []BrokerTopRow `json:"top_buyers"`
|
||||
TopSellers []BrokerTopRow `json:"top_sellers"`
|
||||
}
|
||||
|
||||
// BrokerRow is one per-broker daily row (blot/bval buy, slot/sval sell, nlot/nval net).
|
||||
type BrokerRow struct {
|
||||
BrokerCode string `json:"broker_code"`
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
BFreq int `json:"bfreq"`
|
||||
BLot int64 `json:"blot"`
|
||||
BVal int64 `json:"bval"`
|
||||
BAvg float64 `json:"bavg_per_share"`
|
||||
SFreq int `json:"sfreq"`
|
||||
SLot int64 `json:"slot"`
|
||||
SVal int64 `json:"sval"`
|
||||
SAvg float64 `json:"savg_per_share"`
|
||||
NLot int64 `json:"nlot"`
|
||||
NVal int64 `json:"nval"`
|
||||
NAvg float64 `json:"navg_per_share"`
|
||||
}
|
||||
|
||||
// BrokerRegistryRow classifies one broker code.
|
||||
type BrokerRegistryRow struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IsForeign bool `json:"is_foreign"`
|
||||
Cohort *string `json:"cohort"`
|
||||
LicenseType *string `json:"license_type"`
|
||||
}
|
||||
|
||||
// TopBrokerRow is one brokers/top/ ranking row.
|
||||
type TopBrokerRow struct {
|
||||
Rank int `json:"rank"`
|
||||
BrokerCode string `json:"broker_code"`
|
||||
Gross int64 `json:"gross"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
// ForeignPoint is one day of net foreign inflow (IDR, signed).
|
||||
type ForeignPoint struct {
|
||||
Date string `json:"date"`
|
||||
NetForeignInflow int64 `json:"net_foreign_inflow"`
|
||||
}
|
||||
|
||||
// ForeignFlow is GET foreign-flow/{symbol}/ response.
|
||||
type ForeignFlow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Data []ForeignPoint `json:"data"`
|
||||
}
|
||||
|
||||
// DailyBar is one daily OHLCV row.
|
||||
type DailyBar struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Date string `json:"date"`
|
||||
Close int64 `json:"close"`
|
||||
Open int64 `json:"open"`
|
||||
High int64 `json:"high"`
|
||||
Low int64 `json:"low"`
|
||||
Volume int64 `json:"volume"`
|
||||
MarketCap int64 `json:"market_cap"`
|
||||
}
|
||||
|
||||
// MoverRow is one top-changes entry; PriceChange is decimal (0.05 = +5%).
|
||||
type MoverRow struct {
|
||||
Name string `json:"name"`
|
||||
Symbol string `json:"symbol"`
|
||||
PriceChange float64 `json:"price_change"`
|
||||
LastClosePrice int64 `json:"last_close_price"`
|
||||
LatestCloseDate string `json:"latest_close_date"`
|
||||
}
|
||||
|
||||
// CloseRow is one close/ universe row.
|
||||
type CloseRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Date string `json:"date"`
|
||||
Close int64 `json:"close"`
|
||||
}
|
||||
|
||||
// NewsArticle is one normalized news item.
|
||||
type NewsArticle struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Source string `json:"source"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Sector string `json:"sector"`
|
||||
SubSector []string `json:"sub_sector"`
|
||||
Tags []string `json:"tags"`
|
||||
Symbols []string `json:"symbols"`
|
||||
}
|
||||
|
||||
// Filing is one insider/institution transaction.
|
||||
type Filing struct {
|
||||
Title string `json:"title"`
|
||||
Symbol string `json:"symbol"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
HolderType string `json:"holder_type"`
|
||||
HolderName string `json:"holder_name"`
|
||||
AmountTransaction int64 `json:"amount_transaction"`
|
||||
Price float64 `json:"price"`
|
||||
TransactionValue float64 `json:"transaction_value"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// Suspension is one IDX suspension notice.
|
||||
type Suspension struct {
|
||||
Symbol string `json:"symbol"`
|
||||
SuspensionDate string `json:"suspension_date"`
|
||||
Reason string `json:"reason"`
|
||||
PDFURL string `json:"pdf_url"`
|
||||
}
|
||||
|
||||
// ScreenerRow is one companies/ result row.
|
||||
type ScreenerRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CompanyName string `json:"company_name"`
|
||||
QueryValues map[string]any `json:"query_values"`
|
||||
}
|
||||
|
||||
// QuarterRow is one financials/quarterly row.
|
||||
type QuarterRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Date string `json:"date"`
|
||||
Revenue *float64 `json:"revenue"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
Assets *float64 `json:"total_assets"`
|
||||
Equity *float64 `json:"total_equity"`
|
||||
OpCash *float64 `json:"operating_cash_flow"`
|
||||
Debt *float64 `json:"total_debt"`
|
||||
}
|
||||
|
||||
// DividendEvent is one dividend corporate-action event (raw map: schema untyped).
|
||||
type DividendEvent map[string]any
|
||||
|
||||
// CorporateActions groups events by type.
|
||||
type CorporateActions struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Dividend []DividendEvent `json:"dividend"`
|
||||
UpcomingDividend []DividendEvent `json:"upcoming_dividend"`
|
||||
AGM []DividendEvent `json:"agm"`
|
||||
StockSplit []DividendEvent `json:"stock_split"`
|
||||
}
|
||||
|
||||
// QuarterlyDateRow is one companies/quarterly-financial-dates entry.
|
||||
type QuarterlyDateRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Date string `json:"date"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
// FreeFloatRow is one free-float/ entry (decimal, 0.45 = 45%).
|
||||
type FreeFloatRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CompanyName string `json:"company_name"`
|
||||
FreeFloat float64 `json:"free_float"`
|
||||
}
|
||||
|
||||
// IdxTotalPoint is one idx-total/ row (raw map: shape varies by date range).
|
||||
type IdxTotalPoint map[string]any
|
||||
|
||||
// SegmentRow is one revenue-breakdown entry (Sankey-ready source/target).
|
||||
type SegmentRow struct {
|
||||
Value float64 `json:"value"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// Segments is GET company/get-segments/{symbol}/ response.
|
||||
type Segments struct {
|
||||
Symbol string `json:"symbol"`
|
||||
FinancialYear int `json:"financial_year"`
|
||||
RevenueBreakdown []SegmentRow `json:"revenue_breakdown"`
|
||||
}
|
||||
|
||||
// QuarterlyDate is one [report_date, quarter_label] pair from
|
||||
// get_quarterly_financial_dates/{symbol}/.
|
||||
type QuarterlyDate struct {
|
||||
ReportDate string `json:"report_date"`
|
||||
Quarter string `json:"quarter"`
|
||||
}
|
||||
|
||||
// ListingPerformance is GET listing-performance/{symbol}/ (IPO context).
|
||||
type ListingPerformance struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CompanyName string `json:"company_name"`
|
||||
ListingDate string `json:"listing_date"`
|
||||
Chg7d *float64 `json:"chg_7d"`
|
||||
Chg30d *float64 `json:"chg_30d"`
|
||||
Chg90d *float64 `json:"chg_90d"`
|
||||
Chg365d *float64 `json:"chg_365d"`
|
||||
OfferingPrice *int64 `json:"offering_price"`
|
||||
}
|
||||
|
||||
// IndexBar is one index-daily/{index_code}/ row.
|
||||
type IndexBar struct {
|
||||
IndexCode string `json:"index_code"`
|
||||
Date string `json:"date"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
// ShareholderRow is one shareholders-composition/{symbol}/ month row
|
||||
// (local _l vs foreign _f holder mix across 9 categories).
|
||||
type ShareholderRow struct {
|
||||
Date string `json:"date"`
|
||||
TotalL *int64 `json:"total_l"`
|
||||
TotalF *int64 `json:"total_f"`
|
||||
IndividualL *int64 `json:"individual_l"`
|
||||
IndividualF *int64 `json:"individual_f"`
|
||||
CorporateL *int64 `json:"corporate_l"`
|
||||
CorporateF *int64 `json:"corporate_f"`
|
||||
}
|
||||
|
||||
// ShareholdersComposition is GET company/shareholders-composition/{symbol}/.
|
||||
type ShareholdersComposition struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Year int `json:"year"`
|
||||
Data []ShareholderRow `json:"data"`
|
||||
}
|
||||
Reference in New Issue
Block a user