Files
flowsight/backend/internal/sectors/client.go
T
asepharyana 8c184ccae1 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.
2026-09-15 12:36:48 +07:00

277 lines
7.6 KiB
Go

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