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:
asepharyana
2026-09-15 12:36:48 +07:00
parent 0db2cf28b3
commit 8c184ccae1
111 changed files with 12349 additions and 62 deletions
+111
View File
@@ -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}
}
+231
View File
@@ -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)
}
}
+132
View File
@@ -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", &registry)
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
}
+229
View File
@@ -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)
}
+249
View File
@@ -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
}
+210
View File
@@ -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
}
}
+166
View File
@@ -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
}
+206
View File
@@ -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 + "]"
}
+268
View File
@@ -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
}