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,229 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// AnalyzeCatalyst (A6) builds the catalyst calendar: ex-div, earnings, AGM.
|
||||
// Opportunity score = yield x certainty - earnings-risk, 0-100.
|
||||
func AnalyzeCatalyst(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
ticker = strings.ToUpper(ticker)
|
||||
now := d.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
res := model.AgentResult{Summary: "no catalyst snapshots available"}
|
||||
|
||||
var wrapped struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CorporateActions sectors.CorporateActions `json:"corporate_actions"`
|
||||
// Unwrapped shape (client return) also accepted.
|
||||
Dividend []sectors.DividendEvent `json:"dividend"`
|
||||
UpcomingDividend []sectors.DividendEvent `json:"upcoming_dividend"`
|
||||
AGM []sectors.DividendEvent `json:"agm"`
|
||||
StockSplit []sectors.DividendEvent `json:"stock_split"`
|
||||
}
|
||||
var ipo sectors.ListingPerformance
|
||||
ipoDate, ipoOK := payload(d.DB, ticker, "listing-performance", &ipo)
|
||||
actDate, actOK := payload(d.DB, ticker, "corporate-actions", &wrapped)
|
||||
actions := wrapped.CorporateActions
|
||||
if len(actions.UpcomingDividend) == 0 {
|
||||
actions.UpcomingDividend = wrapped.UpcomingDividend
|
||||
}
|
||||
if len(actions.Dividend) == 0 {
|
||||
actions.Dividend = wrapped.Dividend
|
||||
}
|
||||
if len(actions.AGM) == 0 {
|
||||
actions.AGM = wrapped.AGM
|
||||
}
|
||||
if len(actions.StockSplit) == 0 {
|
||||
actions.StockSplit = wrapped.StockSplit
|
||||
}
|
||||
if ipoOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/listing-performance/"+ticker+"/", ticker, ipoDate))
|
||||
}
|
||||
var qdates []sectors.QuarterlyDate
|
||||
qdDate, qdOK := payload(d.DB, ticker, "quarterly-dates", &qdates)
|
||||
if !qdOK || len(qdates) == 0 {
|
||||
// Legacy universe shape: [{symbol, date, year}] from
|
||||
// companies/quarterly-financial-dates.
|
||||
var uni []sectors.QuarterlyDateRow
|
||||
if ud, uok := payload(d.DB, ticker, "quarterly-dates", &uni); uok {
|
||||
qdDate, qdOK = ud, true
|
||||
for _, r := range uni {
|
||||
qdates = append(qdates, sectors.QuarterlyDate{ReportDate: r.Date})
|
||||
}
|
||||
}
|
||||
}
|
||||
if !actOK && !qdOK {
|
||||
return res
|
||||
}
|
||||
if actOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/company/corporate-actions/"+ticker+"/", ticker, actDate))
|
||||
}
|
||||
if qdOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/company/get_quarterly_financial_dates/"+ticker+"/", ticker, qdDate))
|
||||
}
|
||||
|
||||
type cal struct {
|
||||
event string
|
||||
date string
|
||||
days int
|
||||
extra string
|
||||
}
|
||||
var rows []cal
|
||||
|
||||
closePx, _, _ := d.DB.LatestClose(ticker)
|
||||
for _, ev := range actions.UpcomingDividend {
|
||||
if dt := strAt(ev, "ex_date", "exDate", "date"); len(dt) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", dt[:10]); err == nil {
|
||||
days := int(t.Sub(now).Hours() / 24)
|
||||
rows = append(rows, cal{"ex-div", dt[:10], days, yieldLine(ev, closePx)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ev := range actions.Dividend {
|
||||
if dt := strAt(ev, "ex_date", "exDate", "date"); len(dt) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", dt[:10]); err == nil && t.After(now.AddDate(0, 0, -370)) {
|
||||
days := int(t.Sub(now).Hours() / 24)
|
||||
if days >= -30 { // recent history for payout context
|
||||
rows = append(rows, cal{"div-paid", dt[:10], days, yieldLine(ev, closePx)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ev := range actions.AGM {
|
||||
if dt := strAt(ev, "date", "agm_date"); len(dt) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", dt[:10]); err == nil {
|
||||
if days := int(t.Sub(now).Hours() / 24); days >= 0 {
|
||||
rows = append(rows, cal{"AGM", dt[:10], days, ""})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ev := range actions.StockSplit {
|
||||
if dt := strAt(ev, "date", "ex_date", "split_date"); len(dt) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", dt[:10]); err == nil {
|
||||
if days := int(t.Sub(now).Hours() / 24); days >= -30 {
|
||||
rows = append(rows, cal{"split", dt[:10], days, ratioLine(ev)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// IPO-window context for recent listings (<=365d): anniversary + 30d drift.
|
||||
if ipoOK && len(ipo.ListingDate) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", ipo.ListingDate[:10]); err == nil {
|
||||
age := int(now.Sub(t).Hours() / 24)
|
||||
if age >= 0 && age <= 365 {
|
||||
rows = append(rows, cal{"IPO-window", ipo.ListingDate[:10], -age, ipoLine(&ipo)})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Next earnings estimate: last report + ~90d unless universe dates show newer.
|
||||
if len(qdates) > 0 {
|
||||
sort.Slice(qdates, func(i, j int) bool { return qdates[i].ReportDate > qdates[j].ReportDate })
|
||||
last := qdates[0].ReportDate
|
||||
if len(last) >= 10 {
|
||||
if t, err := time.Parse("2006-01-02", last[:10]); err == nil {
|
||||
next := t.AddDate(0, 0, 90)
|
||||
rows = append(rows, cal{"earnings-est", next.Format("2006-01-02"), int(next.Sub(now).Hours() / 24), "from last " + last[:10]})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].days < rows[j].days })
|
||||
|
||||
opp := 0.0
|
||||
var lines []string
|
||||
for _, r := range rows {
|
||||
h := fmt.Sprintf("H%+d", r.days)
|
||||
if r.days >= 0 {
|
||||
h = fmt.Sprintf("H-%d", r.days)
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %s", r.event, r.date, h)
|
||||
if r.extra != "" {
|
||||
line += " (" + r.extra + ")"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
// Near-term certain events lift the opportunity score.
|
||||
if r.days >= 0 && r.days <= 30 {
|
||||
w := 30.0
|
||||
if r.event == "ex-div" {
|
||||
w = 45
|
||||
}
|
||||
opp += w * (1 - float64(r.days)/30)
|
||||
}
|
||||
}
|
||||
// Earnings within 7d adds risk (results can invalidate the thesis).
|
||||
for _, r := range rows {
|
||||
if r.event == "earnings-est" && r.days >= 0 && r.days <= 7 {
|
||||
opp -= 15
|
||||
res.Flags = append(res.Flags, "earnings-risk")
|
||||
}
|
||||
}
|
||||
res.Score = clampScore(opp, 0, 100)
|
||||
|
||||
if len(lines) == 0 {
|
||||
res.Summary = "no upcoming catalysts in window"
|
||||
} else {
|
||||
res.Values = []model.Value{{
|
||||
Label: "catalyst calendar",
|
||||
Display: strings.Join(lines, " | "),
|
||||
Citations: res.Citations,
|
||||
}}
|
||||
res.Summary = fmt.Sprintf("%d catalysts, opportunity %.0f", len(lines), res.Score)
|
||||
}
|
||||
res.Extra = map[string]any{"calendar": lines}
|
||||
return res
|
||||
}
|
||||
|
||||
// strAt returns the first present string key.
|
||||
func strAt(ev map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
for ek, v := range ev {
|
||||
if strings.EqualFold(ek, k) {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ratioLine renders "a-for-b" when a split ratio is present.
|
||||
func ratioLine(ev map[string]any) string {
|
||||
a := numAt(ev, "ratio", "split_ratio", "ratio_from")
|
||||
b := numAt(ev, "ratio_to", "ratio_denominator", "new_shares")
|
||||
if a > 0 && b > 0 {
|
||||
return fmt.Sprintf("%.0f-for-%.0f", a, b)
|
||||
}
|
||||
return "split"
|
||||
}
|
||||
|
||||
// ipoLine renders listing age + 30d drift when present.
|
||||
func ipoLine(ipo *sectors.ListingPerformance) string {
|
||||
if ipo.Chg30d != nil {
|
||||
return fmt.Sprintf("30d %+.1f%%", *ipo.Chg30d*100)
|
||||
}
|
||||
return "recent listing"
|
||||
}
|
||||
|
||||
// yieldLine renders "DPS x, yield y%" when figures are present.
|
||||
func yieldLine(ev map[string]any, closePx float64) string {
|
||||
dps := numAt(ev, "dividend_per_share", "dps", "cash_dividend")
|
||||
if dps <= 0 {
|
||||
return ""
|
||||
}
|
||||
if closePx > 0 {
|
||||
return fmt.Sprintf("DPS %.0f, yield %.1f%%", dps, dps/closePx*100)
|
||||
}
|
||||
return fmt.Sprintf("DPS %.0f", dps)
|
||||
}
|
||||
Reference in New Issue
Block a user