- 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.
277 lines
7.6 KiB
Go
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 }
|