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 }
|
||||
Reference in New Issue
Block a user