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,268 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// AnalyzeTechnical (A5) scores momentum + volume anomaly + liquidity.
|
||||
// Anomaly: volume > 2x 20d avg. Liquidity grade from free-float %.
|
||||
func AnalyzeTechnical(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
_ = ctx
|
||||
ticker = strings.ToUpper(ticker)
|
||||
res := model.AgentResult{Summary: "no technical snapshots available"}
|
||||
|
||||
var daily []sectors.DailyBar
|
||||
dailyDate, dailyOK := payload(d.DB, ticker, "daily", &daily)
|
||||
var movers struct {
|
||||
TopGainers map[string][]sectors.MoverRow `json:"top_gainers"`
|
||||
TopLosers map[string][]sectors.MoverRow `json:"top_losers"`
|
||||
}
|
||||
moverDate, moverOK := payload(d.DB, "IDX", "top-changes", &movers)
|
||||
if !dailyOK && !moverOK {
|
||||
if vols, dates, err := d.DB.DailyVolumes(ticker, 25); err == nil && len(vols) > 0 {
|
||||
return technicalFromStored(ticker, vols, dates)
|
||||
}
|
||||
return res
|
||||
}
|
||||
if dailyOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/daily/"+ticker+"/", ticker, dailyDate))
|
||||
}
|
||||
if moverOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/companies/top-changes/", "IDX", moverDate))
|
||||
}
|
||||
|
||||
momentum := "flat"
|
||||
moverRank := ""
|
||||
if moverOK {
|
||||
for period, rows := range movers.TopGainers {
|
||||
for i, r := range rows {
|
||||
if strings.HasPrefix(strings.ToUpper(r.Symbol), ticker) {
|
||||
momentum = "up"
|
||||
moverRank = fmt.Sprintf("top-gainer #%d (%s)", i+1, period)
|
||||
}
|
||||
}
|
||||
}
|
||||
for period, rows := range movers.TopLosers {
|
||||
for i, r := range rows {
|
||||
if strings.HasPrefix(strings.ToUpper(r.Symbol), ticker) {
|
||||
momentum = "down"
|
||||
moverRank = fmt.Sprintf("top-loser #%d (%s)", i+1, period)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volMult, volDate, lastVol := 0.0, "", 0.0
|
||||
if len(daily) >= 21 {
|
||||
win := daily
|
||||
if len(win) > 60 {
|
||||
win = win[len(win)-60:]
|
||||
}
|
||||
base := avgVol(win[:len(win)-1], 20)
|
||||
last := win[len(win)-1]
|
||||
lastVol = float64(last.Volume)
|
||||
if base > 0 {
|
||||
volMult = lastVol / base
|
||||
volDate = last.Date
|
||||
}
|
||||
}
|
||||
|
||||
// Price momentum over the window: last close vs first close.
|
||||
priceChg := 0.0
|
||||
if len(daily) >= 2 {
|
||||
first, last := daily[0], daily[len(daily)-1]
|
||||
if first.Close > 0 {
|
||||
priceChg = float64(last.Close-first.Close) / float64(first.Close)
|
||||
}
|
||||
}
|
||||
if momentum == "flat" {
|
||||
switch {
|
||||
case priceChg > 0.05:
|
||||
momentum = "up"
|
||||
case priceChg < -0.05:
|
||||
momentum = "down"
|
||||
}
|
||||
}
|
||||
if momentum == "up" && priceChg > 0.15 {
|
||||
momentum = "strong"
|
||||
}
|
||||
|
||||
// Relative volume vs market: ticker's latest volume against the
|
||||
// most-traded median for the same session.
|
||||
relVol := 0.0
|
||||
if med, mtDate, ok := mostTradedMedian(d.DB); ok {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/most-traded/", "IDX", mtDate))
|
||||
if med > 0 && lastVol > 0 {
|
||||
relVol = lastVol / med
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "relative volume",
|
||||
Display: fmt.Sprintf("%.1fx most-traded median", relVol),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
liquidity := "unknown"
|
||||
var ff []sectors.FreeFloatRow
|
||||
if ffDate, ok := payload(d.DB, "IDX", "free-float", &ff); ok {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/free-float/", "IDX", ffDate))
|
||||
for _, r := range ff {
|
||||
if strings.HasPrefix(strings.ToUpper(r.Symbol), ticker) {
|
||||
switch {
|
||||
case r.FreeFloat >= 0.4:
|
||||
liquidity = "A"
|
||||
case r.FreeFloat >= 0.25:
|
||||
liquidity = "B"
|
||||
case r.FreeFloat >= 0.1:
|
||||
liquidity = "C"
|
||||
default:
|
||||
liquidity = "D"
|
||||
}
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "free float",
|
||||
Display: fmt.Sprintf("%.0f%% (grade %s)", r.FreeFloat*100, liquidity),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if volMult > 2 {
|
||||
res.Flags = append(res.Flags, "volume-anomaly")
|
||||
}
|
||||
|
||||
score := priceChg * 300
|
||||
if volMult > 1 {
|
||||
score += (volMult - 1) * 10
|
||||
}
|
||||
switch momentum {
|
||||
case "strong":
|
||||
score += 15
|
||||
case "up":
|
||||
score += 8
|
||||
case "down":
|
||||
score -= 8
|
||||
}
|
||||
res.Score = clampScore(score, -100, 100)
|
||||
|
||||
res.Values = append([]model.Value{{
|
||||
Label: "momentum",
|
||||
Display: fmt.Sprintf("%s (%+.1f%% window)", momentum, priceChg*100),
|
||||
Citations: res.Citations,
|
||||
}}, res.Values...)
|
||||
if volMult > 0 {
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "volume anomaly",
|
||||
Display: fmt.Sprintf("%.1fx 20d avg on %s", volMult, volDate),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
if moverRank != "" {
|
||||
res.Values = append(res.Values, model.Value{Label: "mover rank", Display: moverRank, Citations: res.Citations})
|
||||
}
|
||||
res.Summary = fmt.Sprintf("%s momentum %+.1f%%, volume %.1fx, liquidity %s",
|
||||
momentum, priceChg*100, volMult, liquidity)
|
||||
res.Extra = map[string]any{
|
||||
"momentum": momentum, "price_change": priceChg,
|
||||
"volume_mult": volMult, "volume_date": volDate, "liquidity": liquidity,
|
||||
"rel_volume": relVol,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// mtRow is one most-traded entry (volume in shares).
|
||||
type mtRow struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Volume float64 `json:"volume"`
|
||||
}
|
||||
|
||||
// mostTradedMedian returns the median volume across the cached most-traded
|
||||
// snapshot plus its snapshot date. Accepts both stored shapes: the wrapped
|
||||
// {results:[...]} form and the bare array the scheduler persists.
|
||||
func mostTradedMedian(db interface {
|
||||
LatestSnapshot(ticker, source string) (string, string, error)
|
||||
}) (med float64, date string, ok bool) {
|
||||
raw, d, err := db.LatestSnapshot("IDX", "most-traded")
|
||||
if err != nil || raw == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
var vols []float64
|
||||
var wrapped struct {
|
||||
Results []mtRow `json:"results"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &wrapped) == nil && len(wrapped.Results) > 0 {
|
||||
for _, r := range wrapped.Results {
|
||||
if r.Volume > 0 {
|
||||
vols = append(vols, r.Volume)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var rows []mtRow
|
||||
if json.Unmarshal([]byte(raw), &rows) != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Volume > 0 {
|
||||
vols = append(vols, r.Volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vols) == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
sort.Float64s(vols)
|
||||
m := vols[len(vols)/2]
|
||||
if len(vols)%2 == 0 {
|
||||
m = (vols[len(vols)/2-1] + vols[len(vols)/2]) / 2
|
||||
}
|
||||
return m, d, true
|
||||
}
|
||||
|
||||
func avgVol(bars []sectors.DailyBar, n int) float64 {
|
||||
if len(bars) < n {
|
||||
n = len(bars)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, b := range bars[len(bars)-n:] {
|
||||
sum += float64(b.Volume)
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
|
||||
// technicalFromStored derives momentum from stored snapshot volumes.
|
||||
func technicalFromStored(ticker string, vols []float64, dates []string) model.AgentResult {
|
||||
res := model.AgentResult{}
|
||||
last := vols[len(vols)-1]
|
||||
base := avg(vols[:len(vols)-1])
|
||||
mult := 0.0
|
||||
if base > 0 {
|
||||
mult = last / base
|
||||
}
|
||||
res.Score = clampScore((mult-1)*20, -100, 100)
|
||||
res.Citations = []model.Citation{model.Cite("v2/daily/"+ticker+"/", ticker, "stored")}
|
||||
date := ""
|
||||
if len(dates) > 0 {
|
||||
date = dates[len(dates)-1]
|
||||
}
|
||||
res.Values = []model.Value{{
|
||||
Label: "volume anomaly",
|
||||
Display: fmt.Sprintf("%.1fx 20d avg on %s", mult, date),
|
||||
Citations: res.Citations,
|
||||
}}
|
||||
if mult > 2 {
|
||||
res.Flags = append(res.Flags, "volume-anomaly")
|
||||
}
|
||||
res.Summary = fmt.Sprintf("stored-volume momentum %.1fx", mult)
|
||||
res.Extra = map[string]any{"volume_mult": mult, "volume_date": date}
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user