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:
asepharyana
2026-09-15 12:36:48 +07:00
parent 0db2cf28b3
commit 8c184ccae1
111 changed files with 12349 additions and 62 deletions
+276
View File
@@ -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 }
+129
View File
@@ -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)
}
}
+719
View File
@@ -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
}
+239
View File
@@ -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"`
}