- 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.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"flowsight/internal/model"
|
|
)
|
|
|
|
// ScreenRequest is POST /api/screen body.
|
|
type ScreenRequest struct {
|
|
Where string `json:"where"`
|
|
Q string `json:"q"`
|
|
Institutional *struct {
|
|
BrokerScoreMin float64 `json:"broker_score_min"`
|
|
ForeignTrend string `json:"foreign_trend"`
|
|
InsiderBuying bool `json:"insider_buying"`
|
|
VolumeAnomaly bool `json:"volume_anomaly"`
|
|
} `json:"institutional"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// ScreenRow is one ranked result with per-row signal breakdown.
|
|
type ScreenRow struct {
|
|
Symbol string `json:"symbol"`
|
|
Name string `json:"name"`
|
|
Composite float64 `json:"composite"`
|
|
Breakdown map[string]any `json:"breakdown"`
|
|
Citations []model.Citation `json:"citations"`
|
|
}
|
|
|
|
// Screen serves POST /api/screen: companies/ base filter enriched with
|
|
// broker score + foreign trend + insider flag, ranked composite.
|
|
func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
|
|
var req ScreenRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
limit := req.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
// Base universe: live screener when keyed, else stored watchlist.
|
|
var universe []string
|
|
if s.Cfg.HasSectorsKey() && (req.Where != "" || req.Q != "") {
|
|
if rows, err := s.Sectors.Screen(r.Context(), req.Where, req.Q, limit*2, 0); err == nil {
|
|
for _, row := range rows {
|
|
universe = append(universe, strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK")))
|
|
}
|
|
}
|
|
}
|
|
if len(universe) == 0 {
|
|
universe, _ = s.DB.Watchlist(s.userKey(r))
|
|
if len(universe) == 0 {
|
|
universe = s.Cfg.Watchlist
|
|
}
|
|
}
|
|
var rows []ScreenRow
|
|
for _, tk := range universe {
|
|
row := s.scoreTicker(tk)
|
|
if req.Institutional != nil {
|
|
inst := req.Institutional
|
|
if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin {
|
|
continue
|
|
}
|
|
if inst.ForeignTrend != "" {
|
|
if t, _ := row.Breakdown["foreign_trend"].(string); t != inst.ForeignTrend {
|
|
continue
|
|
}
|
|
}
|
|
if inst.InsiderBuying {
|
|
if b, _ := row.Breakdown["insider_buying"].(bool); !b {
|
|
continue
|
|
}
|
|
}
|
|
if inst.VolumeAnomaly {
|
|
if b, _ := row.Breakdown["volume_anomaly"].(bool); !b {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Composite > rows[j].Composite })
|
|
if len(rows) > limit {
|
|
rows = rows[:limit]
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"rows": rows, "count": len(rows)})
|
|
}
|
|
|
|
// scoreTicker computes the composite (broker 40 + foreign 25 + insider 15 + volume 20).
|
|
func (s *Server) scoreTicker(tk string) ScreenRow {
|
|
tk = strings.ToUpper(tk)
|
|
row := ScreenRow{Symbol: tk, Name: tk, Breakdown: map[string]any{}}
|
|
// Broker score from 5d net imbalance.
|
|
brokerScore := 0.0
|
|
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
|
|
pos, neg := 0.0, 0.0
|
|
for _, v := range nets {
|
|
if v > 0 {
|
|
pos += v
|
|
} else {
|
|
neg -= v
|
|
}
|
|
}
|
|
if tot := pos + neg; tot > 0 {
|
|
brokerScore = (pos - neg) / tot * 100
|
|
}
|
|
row.Citations = append(row.Citations, model.Cite("v2/broker-summary/"+tk+"/top/", tk, "stored"))
|
|
}
|
|
// Foreign trend from last-6 series.
|
|
foreignScore, trend := 0.0, "flat"
|
|
if dates, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
|
|
last := nets[len(nets)-1]
|
|
if last > 0 {
|
|
foreignScore, trend = 50, "inflow"
|
|
} else if last < 0 {
|
|
foreignScore, trend = -50, "outflow"
|
|
}
|
|
row.Citations = append(row.Citations, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[len(dates)-1]))
|
|
}
|
|
// Insider flag from filings average.
|
|
insider := s.DB.FilingAvg30(tk) > 0
|
|
// Volume anomaly from stored daily bars.
|
|
volAnom, volMult := false, 0.0
|
|
if vols, _, err := s.DB.DailyVolumes(tk, 21); err == nil && len(vols) >= 2 {
|
|
n := len(vols)
|
|
if a := avgF(vols[:n-1]); a > 0 {
|
|
volMult = vols[n-1] / a
|
|
volAnom = volMult > 2
|
|
}
|
|
row.Citations = append(row.Citations, model.Cite("v2/daily/"+tk+"/", tk, "stored"))
|
|
}
|
|
volScore := 0.0
|
|
if volAnom {
|
|
volScore = 50
|
|
}
|
|
row.Composite = brokerScore*0.4 + foreignScore*0.25 + volScore*0.2
|
|
if insider {
|
|
row.Composite += 7.5
|
|
}
|
|
row.Breakdown = map[string]any{
|
|
"broker": brokerScore, "broker_score": brokerScore,
|
|
"foreign": foreignScore, "foreign_trend": trend,
|
|
"insider": insider, "insider_buying": insider,
|
|
"volume_mult": volMult, "volume_anomaly": volAnom,
|
|
}
|
|
return row
|
|
}
|
|
|
|
func avgF(xs []float64) float64 {
|
|
if len(xs) == 0 {
|
|
return 0
|
|
}
|
|
sum := 0.0
|
|
for _, x := range xs {
|
|
sum += x
|
|
}
|
|
return sum / float64(len(xs))
|
|
}
|