- 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.
269 lines
7.0 KiB
Go
269 lines
7.0 KiB
Go
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
|
|
}
|