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,111 @@
|
||||
// Package agents implements the 7 FlowSight specialists (A1..A6) plus the
|
||||
// Master Synthesizer (A7). Contract per docs/AGENT-SPECS.md:
|
||||
//
|
||||
// analyze(ticker, snapshots) -> AgentResult(values[], score, citations[])
|
||||
//
|
||||
// Agents never fetch live; they read snapshots from the store. Detection
|
||||
// rules and scores are computed locally; the LLM only refines prose and
|
||||
// never invents numbers (every value carries citations).
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/llm"
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
// Deps wires one analysis run.
|
||||
type Deps struct {
|
||||
DB *store.DB
|
||||
LLM *llm.Client
|
||||
TriageModel string
|
||||
SynthModel string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// Analyzer is one specialist.
|
||||
type Analyzer func(ctx context.Context, d Deps, ticker string) model.AgentResult
|
||||
|
||||
// Registry runs in fixed order A1..A6; the synthesizer (A7) runs after.
|
||||
var Registry = []struct {
|
||||
Name string
|
||||
Fn Analyzer
|
||||
}{
|
||||
{"smart-money", AnalyzeSmartMoney},
|
||||
{"broker-intel", AnalyzeBrokerIntel},
|
||||
{"sentiment", AnalyzeSentiment},
|
||||
{"fundamental", AnalyzeFundamental},
|
||||
{"technical", AnalyzeTechnical},
|
||||
{"catalyst", AnalyzeCatalyst},
|
||||
}
|
||||
|
||||
// RunAll executes A1..A6 in parallel via goroutines and returns results in
|
||||
// registry order. One failing agent yields a zero-score result with the error
|
||||
// in Summary — it never aborts the other five.
|
||||
func RunAll(ctx context.Context, d Deps, ticker string) []model.AgentResult {
|
||||
out := make([]model.AgentResult, len(Registry))
|
||||
var wg sync.WaitGroup
|
||||
for i, a := range Registry {
|
||||
wg.Add(1)
|
||||
go func(i int, name string, fn Analyzer) {
|
||||
defer wg.Done()
|
||||
res := fn(ctx, d, ticker)
|
||||
res.Agent = name
|
||||
out[i] = res
|
||||
}(i, a.Name, a.Fn)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// payload loads the newest snapshot for (ticker, source) and unmarshals it.
|
||||
// ok=false when no snapshot exists (agents treat missing input as neutral,
|
||||
// never as an error — the Citations list simply stays short).
|
||||
func payload(db *store.DB, ticker, source string, v any) (date string, ok bool) {
|
||||
raw, d, err := db.LatestSnapshot(ticker, source)
|
||||
if err != nil || raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), v); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// cite builds one citation for (source-as-endpoint, ticker, snapshot date).
|
||||
func cite(source, ticker, date string) model.Citation {
|
||||
return model.Cite("v2/"+source+"/", ticker, date)
|
||||
}
|
||||
|
||||
// avg returns the mean of xs (0 on empty).
|
||||
func avg(xs []float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, x := range xs {
|
||||
sum += x
|
||||
}
|
||||
return sum / float64(len(xs))
|
||||
}
|
||||
|
||||
// clampScore bounds a score to [lo, hi].
|
||||
func clampScore(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fail builds an error result that keeps the pipeline green.
|
||||
func fail(agent, msg string) model.AgentResult {
|
||||
return model.AgentResult{Agent: agent, Summary: "error: " + msg}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/llm"
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
func seedDB(t *testing.T) (*store.DB, Deps) {
|
||||
t.Helper()
|
||||
db, err := store.Open(t.TempDir() + "/agents.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := Deps{DB: db, LLM: llm.New("", ""), Now: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)}
|
||||
return db, d
|
||||
}
|
||||
|
||||
func hasCiteLen(res interface{ GetCitations() int }) {}
|
||||
|
||||
// BBCA accumulation fixture scores > +60 with 3 named brokers cited.
|
||||
func TestSmartMoney(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeSmartMoney(context.Background(), d, "BBCA")
|
||||
if res.Score <= 60 {
|
||||
t.Fatalf("score = %.0f, want > 60", res.Score)
|
||||
}
|
||||
if len(res.Citations) == 0 {
|
||||
t.Fatal("no citations")
|
||||
}
|
||||
extra := res.Extra
|
||||
players, _ := extra["players"].([]string)
|
||||
if len(players) < 3 {
|
||||
t.Fatalf("players = %v, want 3 named brokers", players)
|
||||
}
|
||||
}
|
||||
|
||||
// Financials -> Consumer rotation detected with sign-flip evidence.
|
||||
func TestBrokerIntel(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeBrokerIntel(context.Background(), d, "BBCA")
|
||||
found := false
|
||||
for _, f := range res.Flags {
|
||||
if f == "sector-rotation" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("flags = %v, want sector-rotation", res.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// Sentiment trend with >=2 cited articles + insider summary.
|
||||
func TestSentiment(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeSentiment(context.Background(), d, "BBCA")
|
||||
if len(res.Citations) == 0 {
|
||||
t.Fatal("no citations")
|
||||
}
|
||||
if res.Extra["trend"] != "improving" {
|
||||
t.Fatalf("trend = %v, want improving", res.Extra["trend"])
|
||||
}
|
||||
}
|
||||
|
||||
// BBCA shows P/E vs banks median with cited sections.
|
||||
func TestFundamental(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeFundamental(context.Background(), d, "BBCA")
|
||||
if len(res.Citations) < 2 {
|
||||
t.Fatalf("citations = %d, want >= 2 (report + peers)", len(res.Citations))
|
||||
}
|
||||
found := false
|
||||
for _, v := range res.Values {
|
||||
if v.Label == "valuation vs peers" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing valuation-vs-peers row")
|
||||
}
|
||||
}
|
||||
|
||||
// 3.2x volume spike flagged with date.
|
||||
func TestTechnical(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeTechnical(context.Background(), d, "BBCA")
|
||||
found := false
|
||||
for _, f := range res.Flags {
|
||||
if f == "volume-anomaly" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("flags = %v, want volume-anomaly", res.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// Ex-div date + yield appear with H-N countdown.
|
||||
func TestCatalyst(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeCatalyst(context.Background(), d, "BBCA")
|
||||
if res.Score <= 0 {
|
||||
t.Fatalf("score = %.0f, want > 0 (ex-div in 23d)", res.Score)
|
||||
}
|
||||
cal, _ := res.Extra["calendar"].([]string)
|
||||
if len(cal) == 0 {
|
||||
t.Fatal("empty catalyst calendar")
|
||||
}
|
||||
}
|
||||
|
||||
// Good fundamental + broker selling => HOLD-or-lower with conflict flag.
|
||||
func TestSynthesizerConflict(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
results := RunAll(context.Background(), d, "BBCA")
|
||||
// Simulate broker distribution opposing the fixture's accumulation.
|
||||
for i, r := range results {
|
||||
if r.Agent == "smart-money" {
|
||||
r.Score = -60
|
||||
r.Summary = "distribution (simulated)"
|
||||
results[i] = r
|
||||
}
|
||||
}
|
||||
s := Synthesize(context.Background(), d, "BBCA", Moderate, results)
|
||||
if !s.Conflict {
|
||||
t.Fatal("want conflict flag on fundamental-vs-flow opposition")
|
||||
}
|
||||
if s.Recommendation == "BUY" {
|
||||
t.Fatalf("recommendation = BUY, want HOLD-or-lower on conflict")
|
||||
}
|
||||
}
|
||||
|
||||
// Segments snapshot adds a revenue-segments value row with citation.
|
||||
func TestFundamentalSegments(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeFundamental(context.Background(), d, "BBCA")
|
||||
found := false
|
||||
for _, v := range res.Values {
|
||||
if v.Label == "revenue segments" {
|
||||
found = true
|
||||
if len(v.Citations) == 0 {
|
||||
t.Fatal("segments row has no citations")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing revenue-segments row")
|
||||
}
|
||||
}
|
||||
|
||||
// Falling quarter ROE flags declining-roe: ROE fixture earnings edge up
|
||||
// 100->105 while equity balloons 1000->1500, so ROE falls 10%->7% (-30%).
|
||||
func TestFundamentalDecliningROE(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeFundamental(context.Background(), d, "ROE")
|
||||
found := false
|
||||
for _, f := range res.Flags {
|
||||
if f == "declining-roe" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("flags = %v, want declining-roe", res.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// Splits + IPO window appear on the catalyst calendar when present.
|
||||
func TestCatalystSplitsIPO(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeCatalyst(context.Background(), d, "BBCA")
|
||||
if len(res.Citations) == 0 {
|
||||
t.Fatal("no citations")
|
||||
}
|
||||
}
|
||||
|
||||
// Relative volume cites most-traded: BBCA last volume 288M vs fixture
|
||||
// median 120M => 2.4x row present with a most-traded citation.
|
||||
func TestTechnicalRelVol(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
res := AnalyzeTechnical(context.Background(), d, "BBCA")
|
||||
found := false
|
||||
for _, v := range res.Values {
|
||||
if v.Label == "relative volume" {
|
||||
found = true
|
||||
if len(v.Citations) == 0 {
|
||||
t.Fatal("relative-volume row has no citations")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("values = %v, want relative-volume row", res.Values)
|
||||
}
|
||||
if rv, _ := res.Extra["rel_volume"].(float64); rv < 2.0 || rv > 3.0 {
|
||||
t.Fatalf("rel_volume = %v, want ~2.4", rv)
|
||||
}
|
||||
}
|
||||
|
||||
// Thesis claims carry inline citation markers: every agent line ends
|
||||
// with [endpoint @ date] (or [no snapshot] when input is missing).
|
||||
func TestSynthesizerThesisCites(t *testing.T) {
|
||||
db, d := seedDB(t)
|
||||
defer db.Close()
|
||||
results := RunAll(context.Background(), d, "BBCA")
|
||||
s := Synthesize(context.Background(), d, "BBCA", Moderate, results)
|
||||
if len(s.Thesis) == 0 || len(s.Citations) == 0 {
|
||||
t.Fatal("thesis or citations empty")
|
||||
}
|
||||
if strings.Count(s.Thesis, "[v2/") < 3 {
|
||||
t.Fatalf("want >=3 inline [v2/ markers, got: %s", s.Thesis)
|
||||
}
|
||||
if strings.Count(s.Thesis, "@ 2026-09-11]") < 3 {
|
||||
t.Fatalf("want dated markers, got: %s", s.Thesis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// AnalyzeBrokerIntel (A2) classifies broker behavior and emits sector
|
||||
// rotation on week-over-week sign flips with evidence rows.
|
||||
func AnalyzeBrokerIntel(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
_ = ctx
|
||||
ticker = strings.ToUpper(ticker)
|
||||
res := model.AgentResult{Summary: "no broker snapshots available"}
|
||||
|
||||
var registry []sectors.BrokerRegistryRow
|
||||
regDate, regOK := payload(d.DB, "IDX", "brokers-registry", ®istry)
|
||||
var top struct {
|
||||
Date string `json:"date"`
|
||||
Results []sectors.TopBrokerRow `json:"results"`
|
||||
}
|
||||
topDate, topOK := payload(d.DB, "IDX", "brokers-top", &top)
|
||||
if !regOK && !topOK {
|
||||
return res
|
||||
}
|
||||
if regOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/brokers/", "IDX", regDate))
|
||||
}
|
||||
if topOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/brokers/top/", "IDX", topDate))
|
||||
}
|
||||
|
||||
byCode := map[string]sectors.BrokerRegistryRow{}
|
||||
for _, r := range registry {
|
||||
byCode[r.Code] = r
|
||||
}
|
||||
accum, distrib := 0, 0
|
||||
var lines []string
|
||||
for _, b := range top.Results {
|
||||
row := byCode[b.BrokerCode]
|
||||
origin := "domestic"
|
||||
cohort := "unknown"
|
||||
if row.IsForeign {
|
||||
origin = "foreign"
|
||||
}
|
||||
if row.Cohort != nil && *row.Cohort != "" {
|
||||
cohort = *row.Cohort
|
||||
}
|
||||
class := "neutral"
|
||||
switch {
|
||||
case b.Net > 0:
|
||||
class, accum = "accumulating", accum+1
|
||||
case b.Net < 0:
|
||||
class, distrib = "distributing", distrib+1
|
||||
}
|
||||
if len(lines) < 5 {
|
||||
lines = append(lines, fmt.Sprintf("%s (%s/%s) %s %s",
|
||||
b.BrokerCode, origin, cohort, class, fmtIDR(float64(b.Net))))
|
||||
}
|
||||
}
|
||||
total := accum + distrib
|
||||
score := 0.0
|
||||
if total > 0 {
|
||||
score = float64(accum-distrib) / float64(total) * 100
|
||||
}
|
||||
res.Score = clampScore(score, -100, 100)
|
||||
|
||||
// Rotation: week-over-week sign flip on stored sector nets.
|
||||
var flow struct {
|
||||
Week string `json:"week"`
|
||||
Current map[string]float64 `json:"current"`
|
||||
Previous map[string]float64 `json:"previous"`
|
||||
}
|
||||
flowDate, flowOK := payload(d.DB, "IDX", "sector-flow", &flow)
|
||||
rotFrom, rotTo, rotDelta := "", "", 0.0
|
||||
if flowOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/subsector/report/", "IDX", flowDate))
|
||||
type flip struct {
|
||||
sector string
|
||||
delta float64
|
||||
}
|
||||
var flips []flip
|
||||
for s, cur := range flow.Current {
|
||||
prev := flow.Previous[s]
|
||||
if prev < 0 && cur > 0 {
|
||||
flips = append(flips, flip{s, cur - prev})
|
||||
}
|
||||
}
|
||||
var outflows []flip
|
||||
for s, cur := range flow.Current {
|
||||
prev := flow.Previous[s]
|
||||
if prev > 0 && cur < 0 {
|
||||
outflows = append(outflows, flip{s, prev - cur})
|
||||
}
|
||||
}
|
||||
sort.Slice(flips, func(i, j int) bool { return flips[i].delta > flips[j].delta })
|
||||
sort.Slice(outflows, func(i, j int) bool { return outflows[i].delta > outflows[j].delta })
|
||||
if len(flips) > 0 && len(outflows) > 0 {
|
||||
rotFrom, rotTo, rotDelta = outflows[0].sector, flips[0].sector, flips[0].delta
|
||||
res.Flags = append(res.Flags, "sector-rotation")
|
||||
}
|
||||
}
|
||||
|
||||
res.Values = []model.Value{{
|
||||
Label: "broker behavior",
|
||||
Display: fmt.Sprintf("%d accumulating vs %d distributing", accum, distrib),
|
||||
Citations: res.Citations,
|
||||
}, {
|
||||
Label: "top brokers",
|
||||
Display: strings.Join(lines, "; "),
|
||||
Citations: res.Citations,
|
||||
}}
|
||||
if rotFrom != "" {
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "sector rotation",
|
||||
Display: fmt.Sprintf("%s -> %s (%s swing)", rotFrom, rotTo, fmtIDR(rotDelta)),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
res.Summary = fmt.Sprintf("rotation %s -> %s; score %+.0f", rotFrom, rotTo, score)
|
||||
} else {
|
||||
res.Summary = fmt.Sprintf("no rotation flip; score %+.0f (%d vs %d)", score, accum, distrib)
|
||||
}
|
||||
res.Extra = map[string]any{
|
||||
"accumulating": accum, "distributing": distrib,
|
||||
"rotation_from": rotFrom, "rotation_to": rotTo, "rotation_delta": rotDelta,
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// AnalyzeFundamental (A4) scores valuation vs subsector median + quality.
|
||||
// Rubric: profitability 35, growth 25, leverage 20, payout 20. Grade A-F.
|
||||
func AnalyzeFundamental(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
_ = ctx
|
||||
ticker = strings.ToUpper(ticker)
|
||||
res := model.AgentResult{Summary: "no fundamental snapshots available"}
|
||||
|
||||
var report map[string]any
|
||||
repDate, repOK := payload(d.DB, ticker, "company-report", &report)
|
||||
var quarters []sectors.QuarterRow
|
||||
qDate, qOK := payload(d.DB, ticker, "financials-quarterly", &quarters)
|
||||
if !repOK && !qOK {
|
||||
return res
|
||||
}
|
||||
if repOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/company/report/"+ticker+"/", ticker, repDate))
|
||||
}
|
||||
if qOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/financials/quarterly/"+ticker+"/", ticker, qDate))
|
||||
}
|
||||
|
||||
pe, pb, roe, de, payout := numAt(report, "pe_ratio", "pe", "p_e"),
|
||||
numAt(report, "pb_ratio", "pb", "p_b"),
|
||||
numAt(report, "roe", "return_on_equity"),
|
||||
numAt(report, "debt_to_equity", "de_ratio", "der"),
|
||||
numAt(report, "payout_ratio", "dividend_payout")
|
||||
// Normalize fraction-vs-percent inputs: ROE 0.21 == 21%, payout 62 == 62%.
|
||||
if roe > 0 && roe < 1 {
|
||||
roe *= 100
|
||||
}
|
||||
if payout > 1 {
|
||||
payout /= 100
|
||||
}
|
||||
|
||||
// Revenue segments (Sankey-ready) feed the quality read on concentration.
|
||||
var segs sectors.Segments
|
||||
segNames := ""
|
||||
if segDate, segOK := payload(d.DB, ticker, "segments", &segs); segOK && len(segs.RevenueBreakdown) > 0 {
|
||||
top := segs.RevenueBreakdown
|
||||
for i := range top {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
segNames += ", "
|
||||
}
|
||||
segNames += top[i].Source + "→" + top[i].Target
|
||||
}
|
||||
res.Citations = append(res.Citations, model.Cite("v2/company/get-segments/"+ticker+"/", ticker, segDate))
|
||||
}
|
||||
|
||||
var peerPE, peerPB float64
|
||||
var subsector string
|
||||
var sub map[string]any
|
||||
subDate, subOK := payload(d.DB, "IDX", "subsector-valuation", &sub)
|
||||
if subOK {
|
||||
subsector, _ = sub["sub_sector"].(string)
|
||||
peerPE = numAt(sub, "median_pe", "pe_median")
|
||||
peerPB = numAt(sub, "median_pb", "pb_median")
|
||||
res.Citations = append(res.Citations, model.Cite("v2/subsector/report/", subsector, subDate))
|
||||
}
|
||||
|
||||
// 8-quarter revenue/earnings trend.
|
||||
revTrend, earnTrend := 0.0, 0.0
|
||||
if len(quarters) >= 2 {
|
||||
n := len(quarters)
|
||||
if n > 8 {
|
||||
quarters = quarters[n-8:]
|
||||
n = 8
|
||||
}
|
||||
if first, last := fval(quarters[0].Revenue), fval(quarters[n-1].Revenue); first > 0 {
|
||||
revTrend = (last - first) / first
|
||||
}
|
||||
if first, last := fval(quarters[0].Earnings), fval(quarters[n-1].Earnings); first != 0 {
|
||||
earnTrend = (last - first) / abs(first)
|
||||
}
|
||||
}
|
||||
|
||||
// ROE trajectory: falling ROE across quarters flags even when earnings rise.
|
||||
roeSlope := 0.0
|
||||
if len(quarters) >= 2 {
|
||||
first, last := roeOf(quarters[0]), roeOf(quarters[len(quarters)-1])
|
||||
if first > 0 {
|
||||
roeSlope = (last - first) / first
|
||||
}
|
||||
}
|
||||
|
||||
// Weighted rubric 0-100.
|
||||
profit := 50.0
|
||||
if roe > 0 {
|
||||
profit = clampScore(roe*3, 0, 100) // ROE 20%+ => ~60+
|
||||
}
|
||||
growth := clampScore(50+revTrend*200+earnTrend*100, 0, 100)
|
||||
leverage := 60.0
|
||||
if de > 0 {
|
||||
leverage = clampScore(90-de*30, 0, 100) // DER 1x => ~60
|
||||
}
|
||||
pay := 50.0
|
||||
if payout > 0 && payout <= 0.8 {
|
||||
pay = 70
|
||||
} else if payout > 0.8 {
|
||||
pay = 30 // aggressive payout flagged
|
||||
}
|
||||
score := profit*0.35 + growth*0.25 + leverage*0.20 + pay*0.20
|
||||
res.Score = clampScore(score, 0, 100)
|
||||
|
||||
grade := "F"
|
||||
for _, g := range []struct {
|
||||
min float64
|
||||
ch string
|
||||
}{{85, "A"}, {70, "B"}, {55, "C"}, {40, "D"}} {
|
||||
if score >= g.min {
|
||||
grade = g.ch
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var flags []string
|
||||
var vsPeers string
|
||||
if peerPE > 0 && pe > 0 {
|
||||
switch {
|
||||
case pe > peerPE*1.2:
|
||||
vsPeers = fmt.Sprintf("premium P/E %.1f vs %s median %.1f", pe, subsector, peerPE)
|
||||
flags = append(flags, "premium-valuation")
|
||||
case pe < peerPE*0.8:
|
||||
vsPeers = fmt.Sprintf("discount P/E %.1f vs %s median %.1f", pe, subsector, peerPE)
|
||||
flags = append(flags, "discount-valuation")
|
||||
default:
|
||||
vsPeers = fmt.Sprintf("P/E %.1f in line with %s median %.1f", pe, subsector, peerPE)
|
||||
}
|
||||
} else if pe > 0 {
|
||||
vsPeers = fmt.Sprintf("P/E %.1f (no peer median cached)", pe)
|
||||
}
|
||||
if payout > 0.8 {
|
||||
flags = append(flags, "aggressive-payout")
|
||||
}
|
||||
if len(quarters) >= 2 && earnTrend < -0.15 {
|
||||
flags = append(flags, "declining-earnings")
|
||||
}
|
||||
if len(quarters) >= 2 && roeSlope < -0.10 {
|
||||
flags = append(flags, "declining-roe")
|
||||
}
|
||||
res.Flags = flags
|
||||
|
||||
res.Values = []model.Value{{
|
||||
Label: "fundamental score",
|
||||
Display: fmt.Sprintf("%.0f/100 grade %s", score, grade),
|
||||
Citations: res.Citations,
|
||||
}}
|
||||
if vsPeers != "" {
|
||||
res.Values = append(res.Values, model.Value{Label: "valuation vs peers", Display: vsPeers, Citations: res.Citations})
|
||||
}
|
||||
if len(quarters) >= 2 {
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "8Q trend",
|
||||
Display: fmt.Sprintf("revenue %+.0f%%, earnings %+.0f%% over %d quarters", revTrend*100, earnTrend*100, len(quarters)),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
if segNames != "" {
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "revenue segments",
|
||||
Display: segNames,
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
res.Summary = fmt.Sprintf("grade %s score %.0f; %s", grade, score, vsPeers)
|
||||
res.Extra = map[string]any{
|
||||
"grade": grade, "pe": pe, "pb": pb, "roe": roe, "de": de,
|
||||
"payout": payout, "peer_pe": peerPE, "peer_pb": peerPB,
|
||||
"rev_trend": revTrend, "earn_trend": earnTrend,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// numAt digs the first present numeric key out of nested maps.
|
||||
func numAt(m map[string]any, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
if v, ok := lookupNum(m, k); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lookupNum(m map[string]any, key string) (float64, bool) {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, key) {
|
||||
if f, ok := toFloat(v); ok {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
if sub, ok := v.(map[string]any); ok {
|
||||
if f, ok := lookupNum(sub, key); ok {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// roeOf approximates quarter ROE from earnings/equity when both present.
|
||||
func roeOf(q sectors.QuarterRow) float64 {
|
||||
e, eq := fval(q.Earnings), fval(q.Equity)
|
||||
if eq <= 0 {
|
||||
return 0
|
||||
}
|
||||
return e / eq * 100
|
||||
}
|
||||
|
||||
func fval(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// bullish/bearish keyword lists for the offline fallback path (no LLM key).
|
||||
var bullishWords = []string{"laba naik", "profit up", "bullish", "upgrade", "buyback", "dividen naik", "akuisisi", "ekspansi", "rekor", "tumbuh", "naik", "positive", "growth", "record profit"}
|
||||
var bearishWords = []string{"rugi", "turun", "bearish", "downgrade", "suspend", "gagal", "skandal", "fraud", "loss", "drop", "plunge", "warning", "penurunan"}
|
||||
|
||||
// AnalyzeSentiment (A3) aggregates news + filings + suspensions.
|
||||
// Adaptive RAG: LLM triage when configured, keyword fallback offline.
|
||||
// Rare tickers (fewer than 3 articles) force grounding: every claim cites.
|
||||
func AnalyzeSentiment(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
ticker = strings.ToUpper(ticker)
|
||||
res := model.AgentResult{Summary: "no news snapshots available"}
|
||||
|
||||
var news struct {
|
||||
Results []sectors.NewsArticle `json:"results"`
|
||||
}
|
||||
newsDate, newsOK := payload(d.DB, ticker, "news", &news)
|
||||
var filings struct {
|
||||
Results []sectors.Filing `json:"results"`
|
||||
}
|
||||
filDate, filOK := payload(d.DB, ticker, "filings", &filings)
|
||||
var susp struct {
|
||||
Results []sectors.Suspension `json:"results"`
|
||||
}
|
||||
suspDate, suspOK := payload(d.DB, ticker, "suspensions", &susp)
|
||||
if !newsOK && !filOK && !suspOK {
|
||||
// Fall back to derived news_items table (seed path).
|
||||
if arts, err := d.DB.NewsSince(ticker, "2000-01-01"); err == nil && len(arts) > 0 {
|
||||
return sentimentFromStored(ticker, arts)
|
||||
}
|
||||
return res
|
||||
}
|
||||
if newsOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/news/", ticker, newsDate))
|
||||
}
|
||||
if filOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/filings/", ticker, filDate))
|
||||
}
|
||||
if suspOK {
|
||||
res.Citations = append(res.Citations, model.Cite("v2/suspensions/", ticker, suspDate))
|
||||
}
|
||||
|
||||
pos, neg, neu := 0, 0, 0
|
||||
var keyEvents []string
|
||||
useLLM := d.LLM != nil && d.LLM.Available()
|
||||
for i, a := range news.Results {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
label, conf := "neutral", 0.5
|
||||
if useLLM {
|
||||
label, conf = d.LLM.SentimentTriage(ctx, d.TriageModel, a.Title, a.Body)
|
||||
} else {
|
||||
label, conf = keywordSentiment(a.Title + " " + a.Body)
|
||||
}
|
||||
switch label {
|
||||
case "bullish":
|
||||
pos++
|
||||
case "bearish":
|
||||
neg++
|
||||
default:
|
||||
neu++
|
||||
}
|
||||
if len(keyEvents) < 5 && (label != "neutral" || len(news.Results) < 3) {
|
||||
keyEvents = append(keyEvents, fmt.Sprintf("%s [%s %.0f%%]", a.Title, label, conf*100))
|
||||
}
|
||||
_ = conf
|
||||
}
|
||||
|
||||
insiderLine := "no insider filings"
|
||||
buys, sells := 0, 0
|
||||
for _, f := range filings.Results {
|
||||
switch strings.ToLower(f.TransactionType) {
|
||||
case "buy":
|
||||
buys++
|
||||
case "sell":
|
||||
sells++
|
||||
}
|
||||
}
|
||||
if buys+sells > 0 {
|
||||
insiderLine = fmt.Sprintf("%d buys vs %d sells", buys, sells)
|
||||
}
|
||||
|
||||
suspLine := ""
|
||||
if len(susp.Results) > 0 {
|
||||
s := susp.Results[0]
|
||||
suspLine = fmt.Sprintf("SUSPENDED %s: %s", s.SuspensionDate, s.Reason)
|
||||
res.Flags = append(res.Flags, "suspended")
|
||||
}
|
||||
|
||||
total := pos + neg + neu
|
||||
score := 0.0
|
||||
if total > 0 {
|
||||
score = float64(pos-neg) / float64(total)
|
||||
}
|
||||
// Insider tilt: net buys nudge positive.
|
||||
if buys > sells {
|
||||
score += 0.1
|
||||
} else if sells > buys {
|
||||
score -= 0.1
|
||||
}
|
||||
res.Score = clampScore(score, -1, 1)
|
||||
|
||||
trend := "stable"
|
||||
switch {
|
||||
case score > 0.2:
|
||||
trend = "improving"
|
||||
case score < -0.2:
|
||||
trend = "deteriorating"
|
||||
}
|
||||
res.Values = []model.Value{{
|
||||
Label: "sentiment",
|
||||
Display: fmt.Sprintf("%s (bullish %d / bearish %d / neutral %d)", trend, pos, neg, neu),
|
||||
Citations: res.Citations,
|
||||
}, {
|
||||
Label: "insider",
|
||||
Display: insiderLine,
|
||||
Citations: res.Citations,
|
||||
}}
|
||||
if suspLine != "" {
|
||||
res.Values = append(res.Values, model.Value{Label: "suspension", Display: suspLine, Citations: res.Citations})
|
||||
}
|
||||
if len(keyEvents) > 0 {
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "key events",
|
||||
Display: strings.Join(keyEvents, " | "),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
res.Summary = fmt.Sprintf("%s: score %+.2f, %d articles, %s", trend, res.Score, total, insiderLine)
|
||||
res.Extra = map[string]any{
|
||||
"trend": trend, "bullish": pos, "bearish": neg, "neutral": neu,
|
||||
"insider_buys": buys, "insider_sells": sells, "key_events": keyEvents,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// sentimentFromStored builds a result from the derived news_items table.
|
||||
func sentimentFromStored(ticker string, arts []map[string]any) model.AgentResult {
|
||||
res := model.AgentResult{}
|
||||
pos, neg := 0, 0
|
||||
var keys []string
|
||||
for _, a := range arts {
|
||||
s, _ := a["sentiment"].(string)
|
||||
switch s {
|
||||
case "bullish":
|
||||
pos++
|
||||
case "bearish":
|
||||
neg++
|
||||
}
|
||||
if t, _ := a["title"].(string); t != "" && len(keys) < 5 {
|
||||
keys = append(keys, t)
|
||||
}
|
||||
}
|
||||
total := len(arts)
|
||||
score := 0.0
|
||||
if total > 0 {
|
||||
score = float64(pos-neg) / float64(total)
|
||||
}
|
||||
res.Score = clampScore(score, -1, 1)
|
||||
trend := "stable"
|
||||
if score > 0.2 {
|
||||
trend = "improving"
|
||||
} else if score < -0.2 {
|
||||
trend = "deteriorating"
|
||||
}
|
||||
res.Citations = []model.Citation{model.Cite("v2/news/", ticker, "stored")}
|
||||
res.Values = []model.Value{
|
||||
{Label: "sentiment", Display: fmt.Sprintf("%s (%d articles)", trend, total), Citations: res.Citations},
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
res.Values = append(res.Values, model.Value{Label: "key events", Display: strings.Join(keys, " | "), Citations: res.Citations})
|
||||
}
|
||||
res.Summary = fmt.Sprintf("%s: score %+.2f from %d stored articles", trend, res.Score, total)
|
||||
res.Extra = map[string]any{"trend": trend, "key_events": keys}
|
||||
return res
|
||||
}
|
||||
|
||||
// keywordSentiment is the offline fallback classifier.
|
||||
func keywordSentiment(text string) (string, float64) {
|
||||
t := strings.ToLower(text)
|
||||
p, n := 0, 0
|
||||
for _, w := range bullishWords {
|
||||
if strings.Contains(t, w) {
|
||||
p++
|
||||
}
|
||||
}
|
||||
for _, w := range bearishWords {
|
||||
if strings.Contains(t, w) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case p > n:
|
||||
return "bullish", 0.6
|
||||
case n > p:
|
||||
return "bearish", 0.6
|
||||
default:
|
||||
return "neutral", 0.5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/sectors"
|
||||
)
|
||||
|
||||
// fmtIDR renders rupiah compactly (Rp1.2T / Rp340B / Rp12M).
|
||||
func fmtIDR(v float64) string {
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var s string
|
||||
switch {
|
||||
case v >= 1e12:
|
||||
s = fmt.Sprintf("Rp%.2fT", v/1e12)
|
||||
case v >= 1e9:
|
||||
s = fmt.Sprintf("Rp%.0fB", v/1e9)
|
||||
case v >= 1e6:
|
||||
s = fmt.Sprintf("Rp%.0fM", v/1e6)
|
||||
default:
|
||||
s = fmt.Sprintf("Rp%.0f", v)
|
||||
}
|
||||
if neg {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// AnalyzeSmartMoney (A1) fuses broker top lists with foreign flow.
|
||||
// Rule: >=3 brokers net-buy 5d + volume > 1.5x 20d avg => accumulation.
|
||||
func AnalyzeSmartMoney(ctx context.Context, d Deps, ticker string) model.AgentResult {
|
||||
_ = ctx
|
||||
ticker = strings.ToUpper(ticker)
|
||||
res := model.AgentResult{Summary: "no broker snapshots available"}
|
||||
|
||||
var top sectors.BrokerSummaryTop
|
||||
topDate, ok := payload(d.DB, ticker, "broker-summary-top", &top)
|
||||
var foreign sectors.ForeignFlow
|
||||
foreignDate, fok := payload(d.DB, ticker, "foreign-flow", &foreign)
|
||||
if !ok && !fok {
|
||||
return res
|
||||
}
|
||||
|
||||
buySum, sellSum := 0.0, 0.0
|
||||
buyers, sellers := 0, 0
|
||||
var players []string
|
||||
if ok {
|
||||
for _, b := range top.TopBuyers {
|
||||
if b.NetIDR > 0 {
|
||||
buyers++
|
||||
buySum += float64(b.NetIDR)
|
||||
if len(players) < 3 {
|
||||
players = append(players, fmt.Sprintf("%s %s", b.BrokerCode, fmtIDR(float64(b.NetIDR))))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range top.TopSellers {
|
||||
if s.NetIDR < 0 {
|
||||
sellers++
|
||||
sellSum += float64(-s.NetIDR)
|
||||
}
|
||||
}
|
||||
res.Citations = append(res.Citations, model.Cite("v2/broker-summary/"+ticker+"/top/", ticker, topDate))
|
||||
}
|
||||
|
||||
fSum := 0.0
|
||||
fN := 0
|
||||
if fok {
|
||||
data := foreign.Data
|
||||
if len(data) > 5 {
|
||||
data = data[len(data)-5:]
|
||||
}
|
||||
for _, p := range data {
|
||||
fSum += float64(p.NetForeignInflow)
|
||||
fN++
|
||||
}
|
||||
res.Citations = append(res.Citations, model.Cite("v2/foreign-flow/"+ticker+"/", ticker, foreignDate))
|
||||
}
|
||||
|
||||
total := buySum + sellSum
|
||||
imbalance := 0.0
|
||||
if total > 0 {
|
||||
imbalance = (buySum - sellSum) / total
|
||||
}
|
||||
score := imbalance * 70
|
||||
score += float64(minInt(buyers, 5)-minInt(sellers, 5)) * 4
|
||||
if fok && fN > 0 {
|
||||
if fSum > 0 {
|
||||
score += 10
|
||||
} else if fSum < 0 {
|
||||
score -= 10
|
||||
}
|
||||
}
|
||||
score = clampScore(score, -100, 100)
|
||||
res.Score = score
|
||||
|
||||
// Volume multiple from stored daily bars.
|
||||
volMult := 0.0
|
||||
if vols, _, err := d.DB.DailyVolumes(ticker, 21); err == nil && len(vols) >= 2 {
|
||||
n := len(vols)
|
||||
if a := avg(vols[:n-1]); a > 0 {
|
||||
volMult = vols[n-1] / a
|
||||
res.Citations = append(res.Citations, model.Cite("v2/daily/"+ticker+"/", ticker, "stored"))
|
||||
res.Values = append(res.Values, model.Value{
|
||||
Label: "volume vs 20d avg",
|
||||
Display: fmt.Sprintf("%.1fx", volMult),
|
||||
Citations: res.Citations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
phase := "neutral"
|
||||
switch {
|
||||
case score >= 30:
|
||||
phase = "accumulation"
|
||||
case score <= -30:
|
||||
phase = "distribution"
|
||||
case ok && fok && imbalance*fSum < 0:
|
||||
phase = "conflict"
|
||||
res.Flags = append(res.Flags, "direction-conflict")
|
||||
}
|
||||
if ok && fok && imbalance*fSum > 0 {
|
||||
res.Flags = append(res.Flags, "direction-agreement")
|
||||
}
|
||||
if buyers >= 3 && volMult > 1.5 {
|
||||
res.Flags = append(res.Flags, "accumulation-rule")
|
||||
}
|
||||
if volMult > 2 {
|
||||
res.Flags = append(res.Flags, "volume-anomaly")
|
||||
}
|
||||
|
||||
res.Values = append([]model.Value{{
|
||||
Label: "broker net imbalance",
|
||||
Display: fmt.Sprintf("%s net (%d buyers vs %d sellers)", fmtIDR(buySum-sellSum), buyers, sellers),
|
||||
Citations: res.Citations,
|
||||
}, {
|
||||
Label: "key players",
|
||||
Display: strings.Join(players, ", "),
|
||||
Citations: res.Citations,
|
||||
}, {
|
||||
Label: "foreign 5d net",
|
||||
Display: fmtIDR(fSum),
|
||||
Citations: res.Citations,
|
||||
}}, res.Values...)
|
||||
res.Summary = fmt.Sprintf("%s: score %+.0f, %d net-buy brokers, foreign %s",
|
||||
phase, score, buyers, fmtIDR(fSum))
|
||||
res.Extra = map[string]any{
|
||||
"phase": phase, "buyers": buyers, "sellers": sellers,
|
||||
"net_sum": buySum - sellSum, "foreign_sum": fSum,
|
||||
"volume_mult": volMult, "players": players,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
)
|
||||
|
||||
// RiskProfile shifts signal weights: conservative is fundamental-heavy,
|
||||
// aggressive leans into technical + broker flows.
|
||||
type RiskProfile string
|
||||
|
||||
const (
|
||||
Conservative RiskProfile = "conservative"
|
||||
Moderate RiskProfile = "moderate"
|
||||
Aggressive RiskProfile = "aggressive"
|
||||
)
|
||||
|
||||
// Synthesis is the Master Synthesizer (A7) output.
|
||||
type Synthesis struct {
|
||||
Recommendation string `json:"recommendation"` // BUY | HOLD | AVOID
|
||||
Conviction int `json:"conviction"` // 1..5
|
||||
Thesis string `json:"thesis"`
|
||||
PositionPct float64 `json:"position_pct"`
|
||||
Conflict bool `json:"conflict"`
|
||||
ConflictNote string `json:"conflict_note,omitempty"`
|
||||
Scores []string `json:"scores"`
|
||||
Citations []model.Citation `json:"citations"`
|
||||
}
|
||||
|
||||
// Synthesize (A7) weights A1..A6 by risk profile x accuracy-ledger weights,
|
||||
// adds agreement bonus / conflict flag, and sizes via capped Kelly (max 10%).
|
||||
func Synthesize(ctx context.Context, d Deps, ticker string, profile RiskProfile, results []model.AgentResult) Synthesis {
|
||||
_ = ctx
|
||||
ticker = strings.ToUpper(ticker)
|
||||
byAgent := map[string]model.AgentResult{}
|
||||
for _, r := range results {
|
||||
byAgent[r.Agent] = r
|
||||
}
|
||||
|
||||
base := map[string]float64{
|
||||
"smart-money": 0.22, "broker-intel": 0.13, "sentiment": 0.12,
|
||||
"fundamental": 0.25, "technical": 0.15, "catalyst": 0.13,
|
||||
}
|
||||
switch profile {
|
||||
case Conservative:
|
||||
base = map[string]float64{
|
||||
"smart-money": 0.15, "broker-intel": 0.10, "sentiment": 0.10,
|
||||
"fundamental": 0.40, "technical": 0.10, "catalyst": 0.15,
|
||||
}
|
||||
case Aggressive:
|
||||
base = map[string]float64{
|
||||
"smart-money": 0.27, "broker-intel": 0.15, "sentiment": 0.10,
|
||||
"fundamental": 0.13, "technical": 0.25, "catalyst": 0.10,
|
||||
}
|
||||
}
|
||||
|
||||
// Ledger weights: 0.5 until an agent has >=10 resolved calls.
|
||||
weights := map[string]float64{}
|
||||
norm := func(score float64) float64 { return (score + 100) / 200 } // -100..100 -> 0..1
|
||||
get := func(name string, raw, lo, hi float64) float64 {
|
||||
v := raw
|
||||
if hi == 1 && lo == -1 { // sentiment -1..1
|
||||
v = raw * 100
|
||||
} else if hi == 100 && lo == 0 { // fundamental/catalyst 0..100
|
||||
v = raw*2 - 100
|
||||
}
|
||||
return clampScore(v, -100, 100)
|
||||
}
|
||||
_ = norm
|
||||
|
||||
total, wsum := 0.0, 0.0
|
||||
var lines []string
|
||||
var cites []model.Citation
|
||||
for _, a := range []string{"smart-money", "broker-intel", "sentiment", "fundamental", "technical", "catalyst"} {
|
||||
r := byAgent[a]
|
||||
lw := d.DB.AccuracyWeight(a)
|
||||
w := base[a] * (0.5 + lw) // ledger blends in without zeroing anyone
|
||||
weights[a] = w
|
||||
var v float64
|
||||
switch a {
|
||||
case "sentiment":
|
||||
v = get(a, r.Score, -1, 1)
|
||||
case "fundamental", "catalyst":
|
||||
v = get(a, r.Score, 0, 100)
|
||||
default:
|
||||
v = get(a, r.Score, -100, 100)
|
||||
}
|
||||
total += w * v
|
||||
wsum += w
|
||||
lines = append(lines, fmt.Sprintf("%s %+.0f", a, v))
|
||||
cites = append(cites, r.Citations...)
|
||||
}
|
||||
score := 0.0
|
||||
if wsum > 0 {
|
||||
score = total / wsum
|
||||
}
|
||||
|
||||
// Agreement bonus (>=3 aligned) / conflict flag (fundamental vs flows).
|
||||
align := 0
|
||||
for _, a := range []string{"smart-money", "fundamental", "technical", "sentiment"} {
|
||||
r := byAgent[a]
|
||||
v := r.Score
|
||||
if a == "sentiment" {
|
||||
v *= 100
|
||||
} else if a == "fundamental" {
|
||||
v = v*2 - 100
|
||||
}
|
||||
if (score > 0 && v > 0) || (score < 0 && v < 0) {
|
||||
align++
|
||||
}
|
||||
}
|
||||
if align >= 3 {
|
||||
if score > 0 {
|
||||
score += 5
|
||||
} else {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
fund := byAgent["fundamental"].Score*2 - 100
|
||||
flow := byAgent["smart-money"].Score
|
||||
conflict := (fund > 20 && flow < -20) || (fund < -20 && flow > 20)
|
||||
conflictNote := ""
|
||||
if conflict {
|
||||
conflictNote = fmt.Sprintf("fundamental %+.0f opposes smart-money %+.0f", fund, flow)
|
||||
}
|
||||
|
||||
rec := "HOLD"
|
||||
switch {
|
||||
case score >= 25 && !conflict:
|
||||
rec = "BUY"
|
||||
case score <= -25:
|
||||
rec = "AVOID"
|
||||
case conflict && score >= 25:
|
||||
rec = "HOLD" // good fundamental + broker selling => HOLD-or-lower, cited
|
||||
}
|
||||
conviction := 3
|
||||
switch {
|
||||
case score >= 50 || score <= -50:
|
||||
conviction = 5
|
||||
case score >= 35 || score <= -35:
|
||||
conviction = 4
|
||||
case score >= -15 && score <= 15:
|
||||
conviction = 2
|
||||
}
|
||||
if conflict && conviction > 3 {
|
||||
conviction = 3
|
||||
}
|
||||
|
||||
// Capped Kelly: edge from score magnitude, max 10% single name.
|
||||
edge := (score / 100) * 0.5
|
||||
size := edge * 0.25 * 100
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
if size > 10 {
|
||||
size = 10
|
||||
}
|
||||
if rec != "BUY" {
|
||||
size = 0
|
||||
}
|
||||
|
||||
thesis := fmt.Sprintf("%s %s (conviction %d/5): weighted score %+.0f. %s.",
|
||||
ticker, rec, conviction, score, strings.Join(citedLines(byAgent), ", "))
|
||||
if conflict {
|
||||
thesis += " Conflict: " + conflictNote + citeStr(byAgent["smart-money"]) + citeStr(byAgent["fundamental"]) + "."
|
||||
}
|
||||
|
||||
// Record predictions for the +30d accuracy ledger.
|
||||
_ = d.DB.RecordPrediction("synthesizer", ticker, rec)
|
||||
for _, a := range []string{"smart-money", "fundamental", "technical"} {
|
||||
_ = d.DB.RecordPrediction(a, ticker, rec)
|
||||
}
|
||||
|
||||
return Synthesis{
|
||||
Recommendation: rec, Conviction: conviction, Thesis: thesis,
|
||||
PositionPct: size, Conflict: conflict, ConflictNote: conflictNote,
|
||||
Scores: lines, Citations: cites,
|
||||
}
|
||||
}
|
||||
|
||||
// citedLines appends each agent's first citation marker to its score line so
|
||||
// every thesis claim is individually traceable.
|
||||
func citedLines(byAgent map[string]model.AgentResult) []string {
|
||||
var out []string
|
||||
for _, a := range []string{"smart-money", "broker-intel", "sentiment", "fundamental", "technical", "catalyst"} {
|
||||
r := byAgent[a]
|
||||
line := strings.TrimSpace(strings.Split(r.Summary, ";")[0])
|
||||
if line == "" {
|
||||
line = a
|
||||
}
|
||||
out = append(out, line+citeStr(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// citeStr renders "[endpoint @ date]" for an agent's first citation.
|
||||
func citeStr(r model.AgentResult) string {
|
||||
if len(r.Citations) == 0 {
|
||||
return " [no snapshot]"
|
||||
}
|
||||
c := r.Citations[0]
|
||||
return " [" + c.Endpoint + " @ " + c.SnapshotAt + "]"
|
||||
}
|
||||
@@ -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