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
}
+248
View File
@@ -0,0 +1,248 @@
package alerts
import (
"context"
"encoding/json"
"time"
"flowsight/internal/model"
"flowsight/internal/store"
)
// Evaluate runs all 6 rules over stored snapshots for the watchlist and
// records + delivers hits. First-fire only per (rule, ticker, date): rows
// already present in alert_events for today are skipped. Delivery fans out to
// the alert owner's enabled destinations (server env is fallback). Prefer
// EvaluateFor; this wrapper keeps owner empty (server fallback only).
func Evaluate(ctx context.Context, db *store.DB, n *Notifier, alertID int64, ruleJSON string, tickers []string) []Finding {
owner := ""
_ = db.QueryRow(`SELECT user_key FROM alerts WHERE id=?`, alertID).Scan(&owner)
return EvaluateFor(ctx, db, n, alertID, owner, ruleJSON, tickers)
}
// EvaluateFor is Evaluate scoped to an explicit owner: history rows and
// delivery both use owner (no cross-user leakage via alert_id reuse).
func EvaluateFor(ctx context.Context, db *store.DB, n *Notifier, alertID int64, owner, ruleJSON string, tickers []string) []Finding {
today := time.Now().Format("2006-01-02")
var out []Finding
for _, t := range tickers {
cands := candidates(db, t, today)
for _, f := range cands {
if !MatchRule(ruleJSON, f) {
continue
}
if firedToday(db, f.Rule, t, today) {
continue
}
cites, _ := json.Marshal(f.Citations)
ctxJSON, _ := json.Marshal(f.Context)
if _, err := db.InsertAlertEvent(alertID, t, today, f.Message, string(ctxJSON), string(cites), owner); err != nil {
continue
}
_ = n.SendTo(ctx, owner, f, string(cites))
_ = db.MarkAlertFired(alertID)
out = append(out, f)
}
}
return out
}
// candidates runs every rule against one ticker's stored state. Every
// finding carries real citations (no "derived" placeholder).
func candidates(db *store.DB, ticker, today string) []Finding {
var out []Finding
brokerDate := today
if nets, err := db.NetBuySum5d(ticker); err == nil && len(nets) > 0 {
volMult := 0.0
volDate := today
if vols, dates, err := 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
}
volDate = lastOf(dates)
}
if _, d, err := db.LatestSnapshot(ticker, "broker-summary-top"); err == nil {
brokerDate = d
}
if f, ok := Accumulation(ticker, nets, volMult); ok {
f.Citations = []model.Citation{
model.Cite("v2/broker-summary/"+ticker+"/top/", ticker, brokerDate),
model.Cite("v2/daily/"+ticker+"/", ticker, volDate),
}
out = append(out, f)
}
}
if dates, nets, err := db.ForeignLast6(ticker); err == nil && len(nets) == 6 {
if f, ok := ForeignReversal(ticker, nets); ok {
f.Citations = []model.Citation{
model.Cite("v2/foreign-flow/"+ticker+"/", ticker, dates[5]),
}
out = append(out, f)
}
}
// Insider spike: real 2x-volume path (latest buy vs 30d avg) plus the
// distinct-insider cluster path, both over stored filings.
if avg30 := db.FilingAvg30(ticker); avg30 > 0 {
if f, ok := InsiderSpike(ticker, db.LatestBuyVolume(ticker), avg30, db.DistinctInsiders7d(ticker)); ok {
f.Citations = []model.Citation{model.Cite("v2/filings/", ticker, today)}
out = append(out, f)
}
}
if vols, dates, err := db.DailyVolumes(ticker, 21); err == nil && len(vols) >= 2 {
n := len(vols)
if a := avg(vols[:n-1]); a > 0 {
if f, ok := UnusualVolume(ticker, lastOf(dates), vols[n-1]/a, isEarningsDate(db, ticker, lastOf(dates))); ok {
f.Citations = []model.Citation{
model.Cite("v2/daily/"+ticker+"/", ticker, lastOf(dates)),
}
out = append(out, f)
}
}
}
// Sector rotation from the stored sector-flow snapshot.
var flow struct {
Current map[string]float64 `json:"current"`
Previous map[string]float64 `json:"previous"`
}
if raw, d, err := db.LatestSnapshot("IDX", "sector-flow"); err == nil {
_ = json.Unmarshal([]byte(raw), &flow)
for sector, cur := range flow.Current {
if f, ok := SectorRotation(sector, flow.Previous[sector], cur); ok {
f.Citations = []model.Citation{model.Cite("v2/subsector/report/", sector, d)}
out = append(out, f)
}
}
}
// Suspension: fire only on notices dated today or later (new-notice diff).
if raw, d, err := db.LatestSnapshot(ticker, "suspensions"); err == nil && raw != "" {
var notices struct {
Results []struct {
Symbol string `json:"symbol"`
SuspensionDate string `json:"suspension_date"`
Reason string `json:"reason"`
} `json:"results"`
}
if json.Unmarshal([]byte(raw), &notices) == nil {
for _, sn := range notices.Results {
if sn.SuspensionDate >= today {
if f, ok := SuspensionWatch(ticker, sn.SuspensionDate, sn.Reason, true); ok {
f.Citations = []model.Citation{
model.Cite("v2/suspensions/", ticker, sn.SuspensionDate),
}
_ = d
out = append(out, f)
}
}
}
}
}
return out
}
// isEarningsDate reports whether date falls within 1d of a stored quarterly
// report date (rule-4 exclusion for unusual volume).
func isEarningsDate(db *store.DB, ticker, date string) bool {
var qdates []struct {
ReportDate string `json:"report_date"`
Date string `json:"date"`
}
raw, _, err := db.LatestSnapshot(ticker, "quarterly-dates")
if err != nil {
return false
}
if json.Unmarshal([]byte(raw), &qdates) != nil {
return false
}
for _, q := range qdates {
for _, d := range []string{q.ReportDate, q.Date} {
if len(d) >= 10 && len(date) >= 10 && daysBetween(d[:10], date[:10]) <= 1 {
return true
}
}
}
return false
}
func daysBetween(a, b string) int {
pa, ea := parseDay(a)
pb, eb := parseDay(b)
if ea != nil || eb != nil {
return 999
}
h := pa.Sub(pb).Hours() / 24
if h < 0 {
h = -h
}
return int(h + 0.5)
}
func parseDay(s string) (t time.Time, err error) {
return time.Parse("2006-01-02", s)
}
// firedToday enforces first-fire-only per (rule, ticker, date) for every
// rule: any event for the same rule+ticker+date suppresses re-fire.
func firedToday(db *store.DB, rule, ticker, today string) bool {
evts, err := db.AlertEventsSince(today, ticker, 50)
if err != nil {
return false
}
for _, e := range evts {
msg, _ := e["message"].(string)
if e["ticker"] == ticker && e["date"] == today && contains(msg, ruleHints(rule)) {
return true
}
}
return false
}
// ruleHints maps a rule id to the message fragment its findings carry.
func ruleHints(rule string) string {
switch rule {
case RuleAccumulation:
return "accumulation"
case RuleForeignRev:
return "reversal"
case RuleInsiderSpike:
return "insider"
case RuleUnusualVolume:
return "unusual volume"
case RuleRotation:
return "rotation"
case RuleSuspension:
return "suspend"
default:
return rule
}
}
func avg(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
s := 0.0
for _, x := range xs {
s += x
}
return s / float64(len(xs))
}
func lastOf(xs []string) string {
if len(xs) == 0 {
return ""
}
return xs[len(xs)-1]
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+101
View File
@@ -0,0 +1,101 @@
package alerts
import (
"context"
"testing"
"flowsight/internal/store"
)
func seedEvalDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(t.TempDir() + "/eval.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
t.Fatal(err)
}
return db
}
// Accumulation fixture fires with real citations, then first-fire suppresses.
func TestEvaluateAccumulationFirstFire(t *testing.T) {
db := seedEvalDB(t)
n := NewNotifier("", "", "")
id, _ := db.CreateAlert("demo", "all", `{"all":true}`, nil)
got := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
found := false
for _, f := range got {
if f.Rule == RuleAccumulation {
found = true
if len(f.Citations) == 0 {
t.Fatal("accumulation finding has no citations")
}
}
}
if !found {
t.Fatalf("no accumulation finding in %v", got)
}
again := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
for _, f := range again {
if f.Rule == RuleAccumulation {
t.Fatal("accumulation re-fired same day")
}
}
}
// Rotation is reachable: sector-flow flip yields a cited finding.
func TestEvaluateRotationReachable(t *testing.T) {
db := seedEvalDB(t)
n := NewNotifier("", "", "")
id, _ := db.CreateAlert("demo", "all", `{"all":true}`, nil)
got := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
found := false
for _, f := range got {
if f.Rule == RuleRotation {
found = true
}
}
if !found {
t.Fatalf("no rotation finding in %v", ruleIDs(got))
}
}
func ruleIDs(fs []Finding) []string {
var out []string
for _, f := range fs {
out = append(out, f.Rule+":"+f.Ticker)
}
return out
}
// Per-owner delivery: finding fans out to the owner's destinations, and
// history rows carry the owner key (no cross-user leakage on alert_id reuse).
func TestEvaluateForOwnerDelivery(t *testing.T) {
db := seedEvalDB(t)
var sentTo string
var sent int
n := NewNotifier("", "", "")
n.Targets = func(owner string) []Target {
sentTo = owner
sent++
return nil // record-only; just observe routing
}
id, _ := db.CreateAlert("alice", "all", `{"all":true}`, nil)
got := EvaluateFor(context.Background(), db, n, id, "alice", `{"all":true}`, []string{"BBCA"})
if len(got) == 0 {
t.Fatal("want findings on seed")
}
if sent == 0 || sentTo != "alice" {
t.Fatalf("delivery not routed to owner: sent=%d to=%q", sent, sentTo)
}
evts, _ := db.AlertEventsSince("2000-01-01", "", 50, "alice")
if len(evts) == 0 {
t.Fatal("history rows must carry the owner key")
}
if evts2, _ := db.AlertEventsSince("2000-01-01", "", 50, "bob"); len(evts2) != 0 {
t.Fatal("bob must not see alice events")
}
}
+163
View File
@@ -0,0 +1,163 @@
package alerts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Target is one push destination resolved per user at send time.
type Target struct {
TelegramToken string
TelegramChatID string
DiscordURL string
}
// Notifier delivers alert cards to Telegram + Discord webhooks with context +
// citations. Missing credentials degrade to a no-op (logged, not fatal) so
// offline/demo runs never fail on delivery.
type Notifier struct {
TelegramToken string
TelegramChatID string
DiscordURL string
http *http.Client
// Targets, when set, resolves per-owner push destinations. It is consulted
// on every Send so destination CRUD takes effect immediately.
Targets func(owner string) []Target
}
// NewNotifier builds a notifier; empty creds mean dry-run mode.
func NewNotifier(tgToken, tgChat, discordURL string) *Notifier {
return &Notifier{TelegramToken: tgToken, TelegramChatID: tgChat,
DiscordURL: discordURL, http: &http.Client{Timeout: 15 * time.Second}}
}
// DryRun reports whether no server-level channel is configured.
func (n *Notifier) DryRun() bool {
return n.TelegramToken == "" && n.DiscordURL == ""
}
// DryRunFor reports whether an owner has no push target anywhere: neither
// the owner's enabled destinations nor the server fallback.
func (n *Notifier) DryRunFor(owner string) bool {
return len(n.targetsFor(owner)) == 0
}
// targetsFor resolves push targets: per-owner destinations first, then the
// server-level env fallback. Owner "" means server fallback only.
func (n *Notifier) targetsFor(owner string) []Target {
var out []Target
if n.Targets != nil && owner != "" {
out = append(out, n.Targets(owner)...)
}
if n.TelegramToken != "" && n.TelegramChatID != "" {
out = append(out, Target{TelegramToken: n.TelegramToken, TelegramChatID: n.TelegramChatID})
}
if n.DiscordURL != "" {
out = append(out, Target{DiscordURL: n.DiscordURL})
}
return out
}
// Card is the rendered alert text shared by both channels.
func Card(f Finding, cites string) string {
var b strings.Builder
fmt.Fprintf(&b, "*%s* — %s\n%s", f.Ticker, f.Rule, f.Message)
if len(f.Context) > 0 {
raw, _ := json.Marshal(f.Context)
fmt.Fprintf(&b, "\n`%s`", string(raw))
}
if cites != "" {
fmt.Fprintf(&b, "\nCitations: %s", cites)
}
fmt.Fprintf(&b, "\nDetail: /report/%s", f.Ticker)
return b.String()
}
// Send delivers one finding to all configured channels (server fallback).
// Prefer SendTo so delivery fans out to the finding owner's destinations.
func (n *Notifier) Send(ctx context.Context, f Finding, cites string) error {
return n.SendTo(ctx, "", f, cites)
}
// SendTo delivers one finding to the owner's enabled destinations plus the
// server fallback. With no targets anywhere it is record-only (nil).
func (n *Notifier) SendTo(ctx context.Context, owner string, f Finding, cites string) error {
targets := n.targetsFor(owner)
if len(targets) == 0 {
return nil // recorded in alert_events regardless
}
if cites == "" && len(f.Citations) > 0 {
cc, _ := json.Marshal(f.Citations)
cites = string(cc)
}
text := Card(f, cites)
var firstErr error
for _, t := range targets {
if t.TelegramToken != "" && t.TelegramChatID != "" {
if err := sendTelegram(ctx, n.http, t.TelegramToken, t.TelegramChatID, text); err != nil && firstErr == nil {
firstErr = err
}
}
if t.DiscordURL != "" {
if err := sendDiscord(ctx, n.http, t.DiscordURL, text); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func (n *Notifier) telegram(ctx context.Context, text string) error {
return sendTelegram(ctx, n.http, n.TelegramToken, n.TelegramChatID, text)
}
func sendTelegram(ctx context.Context, client *http.Client, token, chatID, text string) error {
u := "https://api.telegram.org/bot" + token + "/sendMessage"
body, _ := json.Marshal(map[string]any{
"chat_id": chatID, "text": text, "parse_mode": "Markdown",
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alerts: telegram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("alerts: telegram HTTP %d", resp.StatusCode)
}
return nil
}
func (n *Notifier) discord(ctx context.Context, text string) error {
return sendDiscord(ctx, n.http, n.DiscordURL, text)
}
func sendDiscord(ctx context.Context, client *http.Client, webhookURL, text string) error {
if len(text) > 1900 {
text = text[:1900] + "…"
}
body, _ := json.Marshal(map[string]any{"content": text})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alerts: discord: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("alerts: discord HTTP %d", resp.StatusCode)
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
// Package alerts evaluates 6 detection rules (docs/PLAN.md §11) over stored
// snapshots. Rules are pure functions over typed inputs so fixture tests can
// prove each fires (or stays silent) deterministically.
package alerts
import (
"strings"
"flowsight/internal/model"
)
// Rule IDs for the 6 v1 detectors.
const (
RuleAccumulation = "accumulation"
RuleForeignRev = "foreign-reversal"
RuleInsiderSpike = "insider-spike"
RuleUnusualVolume = "unusual-volume"
RuleRotation = "sector-rotation"
RuleSuspension = "suspension-watch"
)
// Finding is one rule hit with human text + evidence + real citations.
type Finding struct {
Rule string
Ticker string
Message string
Context map[string]any
Citations []model.Citation
}
// Accumulation fires on >=3 brokers net-buy 5d + volume > 1.5x 20d avg.
func Accumulation(ticker string, netByBroker map[string]float64, volMult float64) (Finding, bool) {
n := 0
sum := 0.0
for _, v := range netByBroker {
if v > 0 {
n++
sum += v
}
}
if n >= 3 && volMult > 1.5 {
return Finding{Rule: RuleAccumulation, Ticker: ticker,
Message: ticker + " accumulation: " + itoa(n) + " brokers net-buy, volume spike",
Context: map[string]any{"brokers": n, "net_sum": sum, "vol_mult": volMult}}, true
}
return Finding{}, false
}
// ForeignReversal fires on 5d cumulative outflow then 1d inflow with the last
// day magnitude > 2x trailing 5d daily average (or the mirror).
func ForeignReversal(ticker string, last6 []float64) (Finding, bool) {
if len(last6) < 6 {
return Finding{}, false
}
prev5, last := last6[:5], last6[5]
sum5 := 0.0
absAvg := 0.0
for _, v := range prev5 {
sum5 += v
absAvg += abs(v)
}
absAvg /= 5
if absAvg == 0 {
return Finding{}, false
}
if sum5 < 0 && last > 0 && last > 2*absAvg {
return Finding{Rule: RuleForeignRev, Ticker: ticker,
Message: ticker + " foreign reversal: outflow flipped to inflow",
Context: map[string]any{"sum5": sum5, "last": last}}, true
}
if sum5 > 0 && last < 0 && -last > 2*absAvg {
return Finding{Rule: RuleForeignRev, Ticker: ticker,
Message: ticker + " foreign reversal: inflow flipped to outflow",
Context: map[string]any{"sum5": sum5, "last": last}}, true
}
return Finding{}, false
}
// InsiderSpike fires on director/major buy volume > 2x 30d avg, or >=3
// distinct insiders buying in 7d.
func InsiderSpike(ticker string, buyVol, avg30 float64, distinct7d int) (Finding, bool) {
if avg30 > 0 && buyVol > 2*avg30 {
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
Message: ticker + " insider spike: buy volume above 2x 30d avg",
Context: map[string]any{"volume": buyVol, "avg30": avg30, "mult": buyVol / avg30}}, true
}
if distinct7d >= 3 {
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
Message: ticker + " insider cluster: 3+ insiders buying in 7d",
Context: map[string]any{"distinct": distinct7d}}, true
}
return Finding{}, false
}
// UnusualVolume fires on >3x 20d avg when the date is not an earnings date.
func UnusualVolume(ticker, date string, mult float64, isEarningsDate bool) (Finding, bool) {
if mult > 3 && !isEarningsDate {
return Finding{Rule: RuleUnusualVolume, Ticker: ticker,
Message: ticker + " unusual volume on " + date,
Context: map[string]any{"mult": mult, "date": date}}, true
}
return Finding{}, false
}
// SectorRotation fires when a subsector net flow flips sign week-over-week.
func SectorRotation(sector string, prev, cur float64) (Finding, bool) {
if (prev < 0 && cur > 0) || (prev > 0 && cur < 0) {
dir := "inflow"
if cur < 0 {
dir = "outflow"
}
return Finding{Rule: RuleRotation, Ticker: sector,
Message: "rotation: " + sector + " flipped to " + dir,
Context: map[string]any{"prev": prev, "cur": cur}}, true
}
return Finding{}, false
}
// SuspensionWatch fires on any new suspension notice for a watchlist ticker.
func SuspensionWatch(ticker, date, reason string, onWatchlist bool) (Finding, bool) {
if reason == "" && date == "" {
return Finding{}, false
}
if onWatchlist {
return Finding{Rule: RuleSuspension, Ticker: ticker,
Message: ticker + " suspended: " + reason,
Context: map[string]any{"date": date, "reason": reason}}, true
}
return Finding{}, false
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
// MatchRule reports whether a user rule JSON targets a finding (simple
// substring match on rule id or ticker; empty rule matches all).
func MatchRule(ruleJSON string, f Finding) bool {
if ruleJSON == "" || ruleJSON == "{}" {
return true
}
r := strings.ToLower(ruleJSON)
return strings.Contains(r, strings.ToLower(f.Rule)) ||
strings.Contains(r, strings.ToLower(f.Ticker)) ||
strings.Contains(r, `"all"`)
}
+76
View File
@@ -0,0 +1,76 @@
package alerts
import "testing"
// Accumulation fixture: 3 brokers net-buy + 1.6x volume fires.
func TestAccumulationFires(t *testing.T) {
nets := map[string]float64{"MG": 5e11, "AK": 4e11, "CC": 3e11, "BK": -1e11}
f, ok := Accumulation("BBCA", nets, 1.6)
if !ok {
t.Fatal("want accumulation fire")
}
if f.Rule != RuleAccumulation {
t.Fatalf("rule = %s", f.Rule)
}
}
func TestAccumulationSilent(t *testing.T) {
nets := map[string]float64{"MG": 5e11, "BK": -4e11}
if _, ok := Accumulation("BBCA", nets, 1.1); ok {
t.Fatal("want silence: only 1 net-buy broker, low volume")
}
}
// Foreign reversal: 5d outflow then strong inflow fires.
func TestForeignReversal(t *testing.T) {
last6 := []float64{-1e11, -1e11, -1e11, -1e11, -1e11, 3.4e11}
if _, ok := ForeignReversal("BBCA", last6); !ok {
t.Fatal("want reversal fire")
}
if _, ok := ForeignReversal("BBCA", []float64{1e11, 1e11}); ok {
t.Fatal("want silence on short series")
}
}
// Insider spike both paths.
func TestInsiderSpike(t *testing.T) {
if _, ok := InsiderSpike("BBCA", 3e6, 1e6, 1); !ok {
t.Fatal("want volume-path fire")
}
if _, ok := InsiderSpike("BBCA", 0, 0, 3); !ok {
t.Fatal("want cluster-path fire")
}
if _, ok := InsiderSpike("BBCA", 1e6, 1e6, 1); ok {
t.Fatal("want silence")
}
}
// Unusual volume suppressed on earnings dates.
func TestUnusualVolume(t *testing.T) {
if _, ok := UnusualVolume("BBCA", "2026-09-11", 3.2, false); !ok {
t.Fatal("want fire")
}
if _, ok := UnusualVolume("BBCA", "2026-09-11", 3.2, true); ok {
t.Fatal("want silence on earnings date")
}
}
// Rotation sign flip.
func TestRotation(t *testing.T) {
if _, ok := SectorRotation("consumer", -3e8, 1.1e9); !ok {
t.Fatal("want rotation fire")
}
if _, ok := SectorRotation("banks", 1e9, 2e9); ok {
t.Fatal("want silence without flip")
}
}
// Suspension watch: watchlist fires, others silent.
func TestSuspension(t *testing.T) {
if _, ok := SuspensionWatch("TLKM", "2026-09-10", "volatilitas", true); !ok {
t.Fatal("want fire on watchlist")
}
if _, ok := SuspensionWatch("TLKM", "2026-09-10", "volatilitas", false); ok {
t.Fatal("want silence off-watchlist")
}
}
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
// ListAlerts serves GET /api/alerts.
func (s *Server) ListAlerts(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListAlerts(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"alerts": rows})
}
// CreateAlert serves POST /api/alerts {name, rule, channels[]}.
func (s *Server) CreateAlert(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name" validate:"required"`
Rule any `json:"rule" validate:"required"`
Channels []string `json:"channels"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "name and rule are required")
return
}
ruleRaw, _ := json.Marshal(req.Rule)
id, err := s.DB.CreateAlert(s.userKey(r), req.Name, string(ruleRaw), req.Channels)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
// DeleteAlert serves DELETE /api/alerts/:id.
func (s *Server) DeleteAlert(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
ok, err := s.DB.DeleteAlert(id, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "alert not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// AlertEvents serves GET /api/alert-events?since=&ticker=.
func (s *Server) AlertEvents(w http.ResponseWriter, r *http.Request) {
since := r.URL.Query().Get("since")
if since == "" {
since = "2000-01-01"
}
evts, err := s.DB.AlertEventsSince(since, r.URL.Query().Get("ticker"), 50, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"events": evts})
}
+273
View File
@@ -0,0 +1,273 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"flowsight/internal/config"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
func testServer(t *testing.T) *Server {
t.Helper()
cfg := config.Load()
cfg.DemoUserKey = "demo"
db, err := store.Open(t.TempDir() + "/api.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
t.Fatal(err)
}
cache := store.NewCache("")
return New(cfg, db, cache, sectors.New(cfg.SectorsBaseURL, ""))
}
func do(s *Server, method, path string, body any) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("X-User-Key", "demo")
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
return rec
}
// Health 200 with cycle + credits fields.
func TestHealth(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/health", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
for _, k := range []string{"last_cycle_at", "credits_today", "scheduler_ok", "stale_flags"} {
if _, ok := out[k]; !ok {
t.Fatalf("missing key %s", k)
}
}
}
// Briefing generates from seed with zero empty sections + citations.
func TestBriefing(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/briefing/today", nil)
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
rows, _ := s.DB.ListRoutines("demo")
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
rec = do(s, "GET", "/api/briefing/today", nil)
}
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
}
// Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out struct {
Rows []map[string]any `json:"rows"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Rows) == 0 {
t.Fatal("empty screener rows")
}
if _, ok := out.Rows[0]["breakdown"]; !ok {
t.Fatal("missing per-row breakdown")
}
}
// Report: all 7 sections populated with citations.
func TestReport(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
}
var out struct {
Sections []map[string]any `json:"sections"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Sections) != 7 {
t.Fatalf("sections = %d, want 7", len(out.Sections))
}
for _, sec := range out.Sections {
if sec["body"] == "" || sec["body"] == nil {
t.Fatalf("empty section %v", sec["name"])
}
}
}
// Subscribe -> run -> history row appears.
func TestRoutineSubscribeRunHistory(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d", rec.Code)
}
var created map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &created)
rows, _ := s.DB.ListRoutines("demo")
if len(rows) == 0 {
t.Fatal("no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
rec = do(s, "GET", "/api/routine-runs?limit=5", nil)
var out struct {
Runs []map[string]any `json:"runs"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Runs) == 0 {
t.Fatal("no history rows")
}
}
// Interrogation is scoped to report citations: conviction Q&A answers
// from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
}
var out struct {
Answer string `json:"answer"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Answer == "" {
t.Fatal("empty interrogation answer")
}
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
}
}
// Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = do(s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
}
// start/end filters narrow the foreign series; reversal uses the 2x rule.
func TestFlowForeignWindow(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/flow/foreign?ticker=BBCA&start=2026-09-11&end=2026-09-11", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
var out struct {
Dates []string `json:"dates"`
Nets []float64 `json:"nets"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Dates) != 1 || out.Dates[0] != "2026-09-11" {
t.Fatalf("dates = %v, want single filtered day", out.Dates)
}
}
// Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = do(s, "DELETE", "/api/routines/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = do(s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
// Destination CRUD: masked list, kind validation, owner scoping, 404s.
func TestDestinations(t *testing.T) {
s := testServer(t)
// Invalid kind -> 422.
rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Telegram without chat_id -> 422.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Discord non-https -> 422.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Valid discord create -> 201.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
// List masks secrets.
rec = do(s, "GET", "/api/destinations", nil)
var out struct {
Destinations []map[string]any `json:"destinations"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if len(out.Destinations) != 1 {
t.Fatalf("destinations = %v", out.Destinations)
}
for _, k := range []string{"bot_token", "chat_id", "webhook_url"} {
if _, ok := out.Destinations[0][k]; ok {
t.Fatalf("secret leaked in list: %s", k)
}
}
if out.Destinations[0]["configured"] != true {
t.Fatalf("configured flag = %v", out.Destinations[0])
}
// Missing id -> 404 on patch and delete.
rec = do(s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = do(s, "DELETE", "/api/destinations/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"net/http"
"strings"
)
// ChatRequest is POST /api/chat {message, scope?: {report_id}}.
type ChatRequest struct {
Message string `json:"message" validate:"required"`
Scope *struct {
ReportID int64 `json:"report_id"`
} `json:"scope"`
}
// Chat serves POST /api/chat: cited answers. With scope.report_id the
// grounding is restricted to that report's citations (report interrogation);
// without scope it answers from latest snapshots. The LLM refines prose only
// — numbers always come from stored data, never from generation.
func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "message is required")
return
}
// Scope grounding: report citations when scoped.
var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string
var at string
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`,
req.Scope.ReportID).Scan(&ground, &cites, &at)
if err != nil {
writeErr(w, http.StatusNotFound, "report not found")
return
}
citesRaw = cites
} else {
// Unscoped: ground on the latest briefing + watchlist.
_, payload, cites, err := s.DB.LatestBriefing()
if err != nil {
ground = "no briefing or report data yet"
} else {
ground, citesRaw = payload, cites
}
}
answer := "Based on stored data: " + head(ground, 600)
if s.LLM.Available() {
if text, err := s.LLM.Complete(r.Context(), s.Cfg.LLMTriage,
"You answer questions about Indonesian stocks using ONLY the grounded data below. "+
"Every number in your answer must cite its source. If the data lacks the answer, say so.",
"Question: "+req.Message+"\n\nGrounded data:\n"+head(ground, 3000), 400); err == nil {
answer = strings.TrimSpace(text)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"answer": answer, "grounding": head(ground, 600), "citations": citesRaw,
})
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"flowsight/internal/store"
)
// destOut is the masked API shape: secrets never leave the server.
type destOut struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
}
func maskDestinations(rows []store.Destination) []destOut {
out := make([]destOut, 0, len(rows))
for _, d := range rows {
cfg := false
switch d.Kind {
case store.DestTelegram:
cfg = d.BotToken != "" && d.ChatID != ""
case store.DestDiscord:
cfg = d.WebhookURL != ""
}
out = append(out, destOut{ID: d.ID, Kind: d.Kind, Label: d.Label, Enabled: d.Enabled, Configured: cfg})
}
return out
}
// ListDestinations serves GET /api/destinations (secrets masked).
func (s *Server) ListDestinations(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListDestinations(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"destinations": maskDestinations(rows)})
}
// CreateDestination serves POST /api/destinations.
func (s *Server) CreateDestination(w http.ResponseWriter, r *http.Request) {
var req struct {
Kind string `json:"kind" validate:"required,oneof=telegram discord"`
Label string `json:"label"`
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
WebhookURL string `json:"webhook_url"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
req.Kind = strings.ToLower(strings.TrimSpace(req.Kind))
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "kind must be telegram or discord")
return
}
if msg := checkDestSecrets(req.Kind, req.BotToken, req.ChatID, req.WebhookURL); msg != "" {
writeErr(w, http.StatusUnprocessableEntity, msg)
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
id, err := s.DB.CreateDestination(store.Destination{
UserKey: s.userKey(r), Kind: req.Kind, Label: req.Label,
BotToken: req.BotToken, ChatID: req.ChatID, WebhookURL: req.WebhookURL,
Enabled: enabled,
})
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "kind": req.Kind})
}
// UpdateDestination serves PATCH /api/destinations/:id. Kind is immutable;
// omitted secret fields keep their stored value.
func (s *Server) UpdateDestination(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Label *string `json:"label"`
Enabled *bool `json:"enabled"`
BotToken *string `json:"bot_token"`
ChatID *string `json:"chat_id"`
WebhookURL *string `json:"webhook_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
ok, err := s.DB.UpdateDestination(id, s.userKey(r), req.Label, req.Enabled, req.BotToken, req.ChatID, req.WebhookURL)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "destination not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// DeleteDestination serves DELETE /api/destinations/:id.
func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
ok, err := s.DB.DeleteDestination(id, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "destination not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// checkDestSecrets validates kind-appropriate secrets.
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
switch kind {
case store.DestTelegram:
if strings.TrimSpace(botToken) == "" || strings.TrimSpace(chatID) == "" {
return "telegram needs bot_token and chat_id"
}
case store.DestDiscord:
u := strings.TrimSpace(webhookURL)
if u == "" {
return "discord needs webhook_url"
}
if !strings.HasPrefix(u, "https://") {
return "webhook_url must be https"
}
default:
return "kind must be telegram or discord"
}
return ""
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strings"
"flowsight/internal/model"
"flowsight/internal/sectors"
)
// FlowSummary serves GET /api/flow/summary: foreign net total, top-5
// accumulation rows, rotation signal, mover of the day — all cited.
func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = "latest"
}
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
wl = s.Cfg.Watchlist
}
type accRow struct {
Ticker string `json:"ticker"`
NetSum float64 `json:"net_sum"`
Brokers int `json:"brokers"`
}
var accs []accRow
foreignTotal := 0.0
var cites []model.Citation
for _, tk := range wl {
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
sum, n := 0.0, 0
for _, v := range nets {
if v > 0 {
n++
sum += v
}
}
accs = append(accs, accRow{tk, sum, n})
cites = append(cites, model.Cite("v2/broker-summary/"+tk+"/top/", tk, date))
}
if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
foreignTotal += nets[len(nets)-1]
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
}
}
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 {
accs = accs[:5]
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal,
"top_accumulation": accs, "citations": cites,
})
}
// brokerQuery validates ticker/start/end query params.
type brokerQuery struct {
Ticker string `validate:"required,len=4"`
Start string `validate:"omitempty,datetime=2006-01-02"`
End string `validate:"omitempty,datetime=2006-01-02"`
}
// FlowBroker serves GET /api/flow/broker: buyers/sellers + 5d net series.
func (s *Server) FlowBroker(w http.ResponseWriter, r *http.Request) {
q := brokerQuery{
Ticker: strings.ToUpper(r.URL.Query().Get("ticker")),
Start: r.URL.Query().Get("start"),
End: r.URL.Query().Get("end"),
}
if err := s.Validate.Struct(q); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "ticker (4 letters) required, dates YYYY-MM-DD")
return
}
var top sectors.BrokerSummaryTop
if raw, d, err := s.DB.SnapshotAt(q.Ticker, "broker-summary-top", q.End); err == nil {
_ = json.Unmarshal([]byte(raw), &top)
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "buyers": top.TopBuyers, "sellers": top.TopSellers,
"citations": []model.Citation{model.Cite("v2/broker-summary/"+q.Ticker+"/top/", q.Ticker, d)},
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "buyers": []any{}, "sellers": []any{},
"citations": []model.Citation{}, "note": "no snapshots yet",
})
}
// FlowForeign serves GET /api/flow/foreign: inflow series + reversal flag.
func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
q := brokerQuery{
Ticker: strings.ToUpper(r.URL.Query().Get("ticker")),
Start: r.URL.Query().Get("start"),
End: r.URL.Query().Get("end"),
}
if err := s.Validate.Struct(q); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "ticker (4 letters) required, dates YYYY-MM-DD")
return
}
dates, nets, err := s.DB.ForeignWindow(q.Ticker, q.Start, q.End, 30)
if err != nil || len(nets) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "series": []any{}, "reversal": false,
"citations": []model.Citation{}, "note": "no snapshots yet",
})
return
}
// Same 2x-magnitude rule as the alert engine: 5d cumulative one way,
// last day the other way at >2x the trailing 5d daily average.
reversal := false
if len(nets) >= 6 {
tail := nets[len(nets)-6:]
sum5, absAvg := 0.0, 0.0
for _, v := range tail[:5] {
sum5 += v
if v < 0 {
absAvg -= v
} else {
absAvg += v
}
}
absAvg /= 5
last := tail[5]
if absAvg > 0 && ((sum5 < 0 && last > 0 && last > 2*absAvg) ||
(sum5 > 0 && last < 0 && -last > 2*absAvg)) {
reversal = true
}
}
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
})
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"net/http"
"time"
"flowsight/internal/model"
)
// Health serves GET /api/health: last cycle time + credits spent today +
// scheduler state + stale flags (docs/API.md).
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") == "1" {
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
}
lastCycle, schedOK := s.Sched.Status()
today := time.Now().Format("2006-01-02")
credits := s.DB.CreditsToday(today)
lastDate, _, _, _ := s.DB.LatestBriefing()
cutoff := model.StaleSession(time.Now())
stale := []string{}
if lastDate != "" {
if t, err := time.Parse("2006-01-02", lastDate[:10]); err == nil && t.Before(cutoff) {
stale = append(stale, "briefing older than one session ("+lastDate+")")
} else if lastDate < today {
stale = append(stale, "briefing older than today ("+lastDate+")")
}
}
if !s.Cfg.HasSectorsKey() {
stale = append(stale, "offline mode: SECTORS_API_KEY unset, serving seed data")
}
writeJSON(w, http.StatusOK, map[string]any{
"last_cycle_at": lastCycle,
"credits_today": credits,
"scheduler_ok": schedOK,
"stale_flags": stale,
"citations": []model.Citation{},
})
}
// Accuracy serves GET /api/accuracy: per-agent {calls, resolved, hits, hit_rate}.
func (s *Server) Accuracy(w http.ResponseWriter, r *http.Request) {
stats, err := s.DB.AccuracyStats()
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if stats == nil {
stats = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"agents": stats})
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// Interrogate serves POST /api/report/:ticker/ask {question, report_id?}:
// follow-up Q&A grounded ONLY in that report's persisted citations.
// Without report_id it uses the latest report for the ticker.
func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
ticker := strings.ToUpper(chi.URLParam(r, "ticker"))
var req struct {
Question string `json:"question" validate:"required"`
ReportID int64 `json:"report_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "question is required")
return
}
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID)
if err != nil {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
return
}
var cites []map[string]any
_ = json.Unmarshal([]byte(citesRaw), &cites)
answer := groundedAnswer(req.Question, payload)
if s.LLM.Available() {
if text, err := s.LLM.Complete(r.Context(), s.Cfg.LLMTriage,
"Answer ONLY from the report JSON below. Every number must quote its cited value. "+
"If the report lacks the answer, say exactly: not in this report.",
"Question: "+req.Question+"\n\nReport:\n"+head(payload, 3000), 400); err == nil && text != "" {
answer = strings.TrimSpace(text)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"answer": answer, "ticker": ticker, "report_id": id,
"generated_at": at, "citations": cites,
})
}
// loadReport fetches (payload, citations, generated_at, id) for an explicit
// report id or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) {
if id > 0 {
var t, p, c, at string
var rid int64
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
FROM reports WHERE id=?`, id).Scan(&rid, &t, &p, &c, &at)
if err != nil {
return "", "", "", 0, err
}
if t != ticker {
return "", "", "", 0, fmt.Errorf("report %d belongs to %s", id, t)
}
return p, c, at, rid, nil
}
p, c, at, err := s.DB.LatestReport(ticker)
if err != nil {
return "", "", "", 0, err
}
var rid int64
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`,
ticker).Scan(&rid)
return p, c, at, rid, nil
}
// groundedAnswer is the offline fallback: it extracts the recommendation +
// conviction + cited lines matching question keywords from the payload.
func groundedAnswer(question, payload string) string {
var rep struct {
Synthesis struct {
Recommendation string `json:"recommendation"`
Conviction int `json:"conviction"`
Thesis string `json:"thesis"`
} `json:"synthesis"`
Sections []struct {
Name string `json:"name"`
Body string `json:"body"`
} `json:"sections"`
}
if err := json.Unmarshal([]byte(payload), &rep); err != nil {
return "not in this report"
}
q := strings.ToLower(question)
if strings.Contains(q, "conviction") || strings.Contains(q, "kenapa") || strings.Contains(q, "why") {
return fmt.Sprintf("%s with conviction %d/5: %s",
rep.Synthesis.Recommendation, rep.Synthesis.Conviction, rep.Synthesis.Thesis)
}
for _, sec := range rep.Sections {
if strings.Contains(q, strings.ToLower(sec.Name)) {
return sec.Body
}
}
return fmt.Sprintf("%s (conviction %d/5): %s",
rep.Synthesis.Recommendation, rep.Synthesis.Conviction, rep.Synthesis.Thesis)
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"encoding/json"
"math"
"net/http"
"sort"
"flowsight/internal/model"
)
// PortfolioRisk serves GET /api/portfolio/risk: concentration bars,
// correlation matrix, beta vs IHSG, warnings (concentrated fixture warns
// >40% sector), accuracy-adjacent citations.
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
wl = s.Cfg.Watchlist
}
// Concentration: weight by latest close x assumed equal shares (seed-safe).
type bar struct {
Ticker string `json:"ticker"`
Sector string `json:"sector"`
Weight float64 `json:"weight"`
}
prices := map[string]float64{}
total := 0.0
for _, tk := range wl {
px, _, err := s.DB.LatestClose(tk)
if err != nil || px <= 0 {
px = 1000 // seed-safe placeholder, flagged in warnings
}
prices[tk] = px
total += px
}
sectorOf := sectorMap()
var bars []bar
sectorW := map[string]float64{}
for _, tk := range wl {
wt := 0.0
if total > 0 {
wt = prices[tk] / total
}
sec := sectorOf[tk]
if sec == "" {
sec = "unknown"
}
bars = append(bars, bar{tk, sec, wt})
sectorW[sec] += wt
}
sort.Slice(bars, func(i, j int) bool { return bars[i].Weight > bars[j].Weight })
var warnings []string
for sec, wt := range sectorW {
if wt > 0.4 {
warnings = append(warnings, "concentrated: "+sec+" at "+pct(wt)+" (over 40%)")
}
}
// Correlation: pairwise Pearson over stored daily closes (aligned tail).
series := s.closes(wl)
corr := correlationMatrixFrom(series, wl)
beta := betaFrom(series, wl)
writeJSON(w, http.StatusOK, map[string]any{
"concentration": bars, "correlation": corr, "beta": beta,
"warnings": warnings,
"citations": []model.Citation{model.Cite("v2/daily/", "watchlist", "stored")},
})
}
func pct(v float64) string {
return itoa(int(v*100+0.5)) + "%"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
// sectorMap is the seed-safe sector lookup (live: subsector/report).
func sectorMap() map[string]string {
return map[string]string{
"BBCA": "financials", "BBRI": "financials", "BMRI": "financials", "BBNI": "financials",
"TLKM": "infrastructure", "ASII": "industrials", "UNVR": "consumer", "ICBP": "consumer",
}
}
// closes returns aligned close series per ticker from snapshots.
func (s *Server) closes(wl []string) map[string][]float64 {
out := map[string][]float64{}
for _, tk := range wl {
var rows []struct {
Close float64 `json:"close"`
}
if raw, _, err := s.DB.LatestSnapshot(tk, "daily"); err == nil {
var bars []struct {
Close float64 `json:"close"`
}
if json.Unmarshal([]byte(raw), &bars) == nil {
for _, b := range bars {
rows = append(rows, struct {
Close float64 `json:"close"`
}{b.Close})
}
}
_ = rows
series := make([]float64, 0, len(bars))
for _, b := range bars {
series = append(series, b.Close)
}
out[tk] = series
}
}
return out
}
func correlationMatrixFrom(series map[string][]float64, wl []string) map[string]map[string]float64 {
m := map[string]map[string]float64{}
for _, a := range wl {
m[a] = map[string]float64{}
for _, b := range wl {
if a == b {
m[a][b] = 1
continue
}
m[a][b] = pearson(tail(series[a], 30), tail(series[b], 30))
}
}
return m
}
func tail(xs []float64, n int) []float64 {
if len(xs) <= n {
return xs
}
return xs[len(xs)-n:]
}
// pearson computes the correlation of two equal-length series.
func pearson(a, b []float64) float64 {
n := len(a)
if n != len(b) || n < 2 {
return 0
}
ma, mb := mean(a), mean(b)
num, da, db := 0.0, 0.0, 0.0
for i := range a {
num += (a[i] - ma) * (b[i] - mb)
da += (a[i] - ma) * (a[i] - ma)
db += (b[i] - mb) * (b[i] - mb)
}
if da == 0 || db == 0 {
return 0
}
return num / (math.Sqrt(da) * math.Sqrt(db))
}
func mean(xs []float64) float64 {
s := 0.0
for _, x := range xs {
s += x
}
return s / float64(len(xs))
}
// betaFrom regresses mean ticker returns vs the watchlist mean (index-daily
// benchmark when cached; watchlist-mean fallback keeps seeds working).
func betaFrom(series map[string][]float64, wl []string) float64 {
if len(wl) == 0 {
return 1
}
n := 0
for _, tk := range wl {
if len(series[tk]) > n {
n = len(series[tk])
}
}
if n < 2 {
return 1
}
idx := make([]float64, n)
for _, tk := range wl {
s := series[tk]
for i := range idx {
if i < len(s) {
idx[i] += s[i]
}
}
}
for i := range idx {
idx[i] /= float64(len(wl))
}
betas := []float64{}
for _, tk := range wl {
if b := betaOf(series[tk], idx); b != 0 {
betas = append(betas, b)
}
}
if len(betas) == 0 {
return 1
}
return mean(betas)
}
// betaOf is cov(asset,index)/var(index) over the aligned tail.
func betaOf(asset, index []float64) float64 {
n := len(asset)
if len(index) < n {
n = len(index)
}
if n < 2 {
return 0
}
a, ix := asset[len(asset)-n:], index[len(index)-n:]
ma, mi := mean(a), mean(ix)
num, den := 0.0, 0.0
for i := range a {
num += (a[i] - ma) * (ix[i] - mi)
den += (ix[i] - mi) * (ix[i] - mi)
}
if den == 0 {
return 0
}
return num / den
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// BuildReport serves POST /api/report/:ticker?format=json|html|pdf|md.
// Runs A1..A6 + A7 live over stored snapshots, persists, and renders.
func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
ticker := strings.ToUpper(chi.URLParam(r, "ticker"))
if len(ticker) < 3 || len(ticker) > 6 {
writeErr(w, http.StatusUnprocessableEntity, "ticker must be 3-6 letters")
return
}
profile := r.URL.Query().Get("profile")
if profile == "" {
profile = "moderate"
}
rep, id, err := s.Builder.Build(r.Context(), ticker, profile)
if err != nil {
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return
}
_ = id
switch strings.ToLower(r.URL.Query().Get("format")) {
case "html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(rep.ToHTML()))
case "md":
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(rep.ToMarkdown()))
case "pdf":
raw, err := rep.ToPDF()
if err != nil {
writeErr(w, http.StatusBadGateway, "pdf: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/pdf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
default:
writeJSON(w, http.StatusOK, rep)
}
// Push a live agent-panel event for the dashboard SSE feed.
s.Hub.Publish("agents", `{"ticker":"`+ticker+`","recommendation":"`+
rep.Synthesis.Recommendation+`"}`)
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"flowsight/internal/routines"
"flowsight/internal/store"
)
// ListRoutines serves GET /api/routines with last-run status.
func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListRoutines(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
type rowOut struct {
store.Routine
LastRun any `json:"last_run"`
}
out := make([]rowOut, 0, len(rows))
for _, row := range rows {
hist, _ := s.DB.RunHistory(row.ID, 1)
var last any
if len(hist) > 0 {
last = hist[0]
}
out = append(out, rowOut{row, last})
}
writeJSON(w, http.StatusOK, map[string]any{"routines": out})
}
// CreateRoutine serves POST /api/routines {type, schedule_cron?, channels[]}.
func (s *Server) CreateRoutine(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type" validate:"required"`
Schedule string `json:"schedule_cron"`
Channels []string `json:"channels"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "type is required")
return
}
if !routines.KnownType(req.Type) {
writeErr(w, http.StatusUnprocessableEntity, "unknown routine type")
return
}
if req.Schedule == "" {
req.Schedule = routines.DefaultSchedule(req.Type)
}
id, err := s.DB.CreateRoutine(s.userKey(r), req.Type, req.Schedule, req.Channels)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "type": req.Type, "schedule_cron": req.Schedule})
}
// UpdateRoutine serves PATCH /api/routines/:id.
func (s *Server) UpdateRoutine(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Enabled *bool `json:"enabled"`
Schedule string `json:"schedule_cron"`
Channels []string `json:"channels"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
ok, err := s.DB.UpdateRoutine(id, s.userKey(r), req.Enabled, req.Schedule, req.Channels)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "routine not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// RunHistory serves GET /api/routine-runs?routine_id=&limit=.
func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
rid, _ := strconv.ParseInt(r.URL.Query().Get("routine_id"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
hist, err := s.DB.RunHistory(rid, limit, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"runs": hist})
}
// BriefingToday serves GET /api/briefing/today: latest payload + citations.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
date, payload, cites, err := s.DB.LatestBriefing()
if err != nil {
writeErr(w, http.StatusNotFound, "no briefing yet — run the morning-briefing routine")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "payload": payload, "citations": cites,
})
}
// DeleteRoutine serves DELETE /api/routines/:id.
func (s *Server) DeleteRoutine(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
ok, err := s.DB.DeleteRoutine(id, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "routine not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strings"
"flowsight/internal/model"
)
// ScreenRequest is POST /api/screen body.
type ScreenRequest struct {
Where string `json:"where"`
Q string `json:"q"`
Institutional *struct {
BrokerScoreMin float64 `json:"broker_score_min"`
ForeignTrend string `json:"foreign_trend"`
InsiderBuying bool `json:"insider_buying"`
VolumeAnomaly bool `json:"volume_anomaly"`
} `json:"institutional"`
Limit int `json:"limit"`
}
// ScreenRow is one ranked result with per-row signal breakdown.
type ScreenRow struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Composite float64 `json:"composite"`
Breakdown map[string]any `json:"breakdown"`
Citations []model.Citation `json:"citations"`
}
// Screen serves POST /api/screen: companies/ base filter enriched with
// broker score + foreign trend + insider flag, ranked composite.
func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
var req ScreenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
limit := req.Limit
if limit <= 0 || limit > 100 {
limit = 20
}
// Base universe: live screener when keyed, else stored watchlist.
var universe []string
if s.Cfg.HasSectorsKey() && (req.Where != "" || req.Q != "") {
if rows, err := s.Sectors.Screen(r.Context(), req.Where, req.Q, limit*2, 0); err == nil {
for _, row := range rows {
universe = append(universe, strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK")))
}
}
}
if len(universe) == 0 {
universe, _ = s.DB.Watchlist(s.userKey(r))
if len(universe) == 0 {
universe = s.Cfg.Watchlist
}
}
var rows []ScreenRow
for _, tk := range universe {
row := s.scoreTicker(tk)
if req.Institutional != nil {
inst := req.Institutional
if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin {
continue
}
if inst.ForeignTrend != "" {
if t, _ := row.Breakdown["foreign_trend"].(string); t != inst.ForeignTrend {
continue
}
}
if inst.InsiderBuying {
if b, _ := row.Breakdown["insider_buying"].(bool); !b {
continue
}
}
if inst.VolumeAnomaly {
if b, _ := row.Breakdown["volume_anomaly"].(bool); !b {
continue
}
}
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Composite > rows[j].Composite })
if len(rows) > limit {
rows = rows[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows, "count": len(rows)})
}
// scoreTicker computes the composite (broker 40 + foreign 25 + insider 15 + volume 20).
func (s *Server) scoreTicker(tk string) ScreenRow {
tk = strings.ToUpper(tk)
row := ScreenRow{Symbol: tk, Name: tk, Breakdown: map[string]any{}}
// Broker score from 5d net imbalance.
brokerScore := 0.0
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
pos, neg := 0.0, 0.0
for _, v := range nets {
if v > 0 {
pos += v
} else {
neg -= v
}
}
if tot := pos + neg; tot > 0 {
brokerScore = (pos - neg) / tot * 100
}
row.Citations = append(row.Citations, model.Cite("v2/broker-summary/"+tk+"/top/", tk, "stored"))
}
// Foreign trend from last-6 series.
foreignScore, trend := 0.0, "flat"
if dates, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
last := nets[len(nets)-1]
if last > 0 {
foreignScore, trend = 50, "inflow"
} else if last < 0 {
foreignScore, trend = -50, "outflow"
}
row.Citations = append(row.Citations, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[len(dates)-1]))
}
// Insider flag from filings average.
insider := s.DB.FilingAvg30(tk) > 0
// Volume anomaly from stored daily bars.
volAnom, volMult := false, 0.0
if vols, _, err := s.DB.DailyVolumes(tk, 21); err == nil && len(vols) >= 2 {
n := len(vols)
if a := avgF(vols[:n-1]); a > 0 {
volMult = vols[n-1] / a
volAnom = volMult > 2
}
row.Citations = append(row.Citations, model.Cite("v2/daily/"+tk+"/", tk, "stored"))
}
volScore := 0.0
if volAnom {
volScore = 50
}
row.Composite = brokerScore*0.4 + foreignScore*0.25 + volScore*0.2
if insider {
row.Composite += 7.5
}
row.Breakdown = map[string]any{
"broker": brokerScore, "broker_score": brokerScore,
"foreign": foreignScore, "foreign_trend": trend,
"insider": insider, "insider_buying": insider,
"volume_mult": volMult, "volume_anomaly": volAnom,
}
return row
}
func avgF(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
sum := 0.0
for _, x := range xs {
sum += x
}
return sum / float64(len(xs))
}
+112
View File
@@ -0,0 +1,112 @@
// Package api serves the FlowSight REST API (docs/API.md) on chi: flow
// summary/broker/foreign, screener, routines, briefing, alerts, reports,
// watchlist, portfolio risk, accuracy, chat (report-scoped), health, and the
// SSE stream (agents/alerts/activity, 15s heartbeat). Demo auth: X-User-Key.
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-playground/validator/v10"
"flowsight/internal/agents"
"flowsight/internal/config"
"flowsight/internal/llm"
"flowsight/internal/reports"
"flowsight/internal/routines"
"flowsight/internal/scheduler"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// Server wires all handlers.
type Server struct {
Cfg config.Config
DB *store.DB
Sectors *sectors.Client
Sched *scheduler.Scheduler
Engine *routines.Engine
Builder *reports.Builder
LLM *llm.Client
Validate *validator.Validate
Hub *Hub
}
// New builds a Server with all dependencies wired.
func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client) *Server {
llmc := llm.New(cfg.LLMBaseURL, cfg.LLMAPIKey)
sched := scheduler.New(cfg, db, cache, s)
srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(),
Hub: NewHub(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
Publish: srv.Hub.Publish}
sched.Publish = srv.Hub.Publish
srv.Builder = &reports.Builder{DB: db, Deps: agents.Deps{
DB: db, LLM: llmc, TriageModel: cfg.LLMTriage, SynthModel: cfg.LLMSynth,
Now: time.Now(),
}}
return srv
}
// Router returns the chi mux with all routes.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping"))
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.Health)
r.Get("/stream", s.Stream)
r.Get("/flow/summary", s.FlowSummary)
r.Get("/flow/broker", s.FlowBroker)
r.Get("/flow/foreign", s.FlowForeign)
r.Post("/screen", s.Screen)
r.Get("/routines", s.ListRoutines)
r.Post("/routines", s.CreateRoutine)
r.Patch("/routines/{id}", s.UpdateRoutine)
r.Delete("/routines/{id}", s.DeleteRoutine)
r.Get("/routine-runs", s.RunHistory)
r.Get("/briefing/today", s.BriefingToday)
r.Get("/alerts", s.ListAlerts)
r.Post("/alerts", s.CreateAlert)
r.Delete("/alerts/{id}", s.DeleteAlert)
r.Get("/alert-events", s.AlertEvents)
r.Get("/destinations", s.ListDestinations)
r.Post("/destinations", s.CreateDestination)
r.Patch("/destinations/{id}", s.UpdateDestination)
r.Delete("/destinations/{id}", s.DeleteDestination)
r.Post("/report/{ticker}", s.BuildReport)
r.Post("/report/{ticker}/ask", s.Interrogate)
r.Get("/watchlist", s.GetWatchlist)
r.Post("/watchlist", s.AddWatch)
r.Delete("/watchlist/{ticker}", s.RemoveWatch)
r.Get("/portfolio/risk", s.PortfolioRisk)
r.Get("/accuracy", s.Accuracy)
r.Post("/chat", s.Chat)
})
return r
}
// userKey resolves the demo auth header (single demo key for hackathon).
func (s *Server) userKey(r *http.Request) string {
if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" {
return k
}
return s.Cfg.DemoUserKey
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]any{"error": map[string]string{"code": http.StatusText(code), "message": msg}})
}
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"fmt"
"net/http"
"sync"
"time"
)
// Hub fans SSE events out to connected browsers. Channels: agents
// (status+scores during runs), alerts (new events), activity (feed rows).
// Heartbeat 15s; reconnect resumes from last event ID (best-effort replay of
// the last 50 events).
type Hub struct {
mu sync.Mutex
subs map[chan SSEEvent]bool
history []SSEEvent
nextID int64
}
// SSEEvent is one server-sent event.
type SSEEvent struct {
ID int64
Channel string
Data string
}
// NewHub builds an empty hub.
func NewHub() *Hub { return &Hub{subs: map[chan SSEEvent]bool{}} }
// Publish broadcasts to all subscribers and appends to history.
func (h *Hub) Publish(channel, data string) {
h.mu.Lock()
h.nextID++
ev := SSEEvent{ID: h.nextID, Channel: channel, Data: data}
h.history = append(h.history, ev)
if len(h.history) > 50 {
h.history = h.history[len(h.history)-50:]
}
for ch := range h.subs {
select {
case ch <- ev:
default:
}
}
h.mu.Unlock()
}
func (h *Hub) subscribe() chan SSEEvent {
ch := make(chan SSEEvent, 16)
h.mu.Lock()
h.subs[ch] = true
h.mu.Unlock()
return ch
}
// since returns history entries newer than id (all when id <= 0).
func (h *Hub) since(id int64) []SSEEvent {
h.mu.Lock()
defer h.mu.Unlock()
var out []SSEEvent
for _, ev := range h.history {
if ev.ID > id {
out = append(out, ev)
}
}
return out
}
// lastEventID parses Last-Event-ID (header or query) for resume.
func lastEventID(r *http.Request) int64 {
s := r.Header.Get("Last-Event-ID")
if s == "" {
s = r.URL.Query().Get("lastEventId")
}
var id int64
fmt.Sscanf(s, "%d", &id)
return id
}
func (h *Hub) unsubscribe(ch chan SSEEvent) {
h.mu.Lock()
delete(h.subs, ch)
close(ch)
h.mu.Unlock()
}
// Stream serves GET /api/stream as text/event-stream.
func (s *Server) Stream(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := s.Hub.subscribe()
defer s.Hub.unsubscribe(ch)
// Best-effort replay: resume after Last-Event-ID so reconnects do not
// lose the last 50 events (matches the history comment on Publish).
for _, ev := range s.Hub.since(lastEventID(r)) {
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Channel, ev.Data)
}
fl.Flush()
tick := time.NewTicker(15 * time.Second)
defer tick.Stop()
fmt.Fprintf(w, ": connected\n\n")
fl.Flush()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Channel, ev.Data)
fl.Flush()
case <-tick.C:
fmt.Fprintf(w, ": heartbeat\n\n")
fl.Flush()
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// GetWatchlist serves GET /api/watchlist.
func (s *Server) GetWatchlist(w http.ResponseWriter, r *http.Request) {
wl, err := s.DB.Watchlist(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"watchlist": wl})
}
// AddWatch serves POST /api/watchlist {ticker}.
func (s *Server) AddWatch(w http.ResponseWriter, r *http.Request) {
var req struct {
Ticker string `json:"ticker" validate:"required,len=4"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
req.Ticker = strings.ToUpper(strings.TrimSpace(req.Ticker))
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "ticker (4 letters) required")
return
}
if err := s.DB.AddWatch(s.userKey(r), req.Ticker); err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"ticker": req.Ticker})
}
// RemoveWatch serves DELETE /api/watchlist/:ticker.
func (s *Server) RemoveWatch(w http.ResponseWriter, r *http.Request) {
ticker := strings.ToUpper(chi.URLParam(r, "ticker"))
if err := s.DB.RemoveWatch(s.userKey(r), ticker); err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ticker": ticker, "ok": true})
}
+87
View File
@@ -0,0 +1,87 @@
// Package config loads FlowSight runtime configuration from the environment.
// Secrets come only from env vars, never from files.
package config
import (
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Config holds all runtime settings for the FlowSight backend.
type Config struct {
Port string
SectorsAPIKey string
SectorsBaseURL string
DBPath string
RedisURL string
DemoUserKey string
LLMBaseURL string
LLMAPIKey string
LLMTriage string
LLMSynth string
TelegramBotToken string
TelegramChatID string
DiscordWebhookURL string
CreditCapPerCycle int
Watchlist []string
}
// HasSectorsKey reports whether live Sectors API calls are possible.
// Without a key the server runs in offline/seed mode.
func (c Config) HasSectorsKey() bool { return strings.TrimSpace(c.SectorsAPIKey) != "" }
// HasLLM reports whether LLM-backed refinement is available.
func (c Config) HasLLM() bool {
return strings.TrimSpace(c.LLMBaseURL) != "" && strings.TrimSpace(c.LLMAPIKey) != ""
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
// Load reads configuration from the environment (.env supported for dev).
func Load() Config {
_ = godotenv.Load()
watch := getenv("WATCHLIST", "BBCA,BBRI,BMRI,TLKM,ASII")
tickers := make([]string, 0, 8)
for _, t := range strings.Split(watch, ",") {
if t = strings.ToUpper(strings.TrimSpace(t)); t != "" {
tickers = append(tickers, t)
}
}
return Config{
Port: getenv("PORT", "8080"),
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
DBPath: getenv("DB_PATH", "data/flowsight.db"),
RedisURL: os.Getenv("REDIS_URL"),
DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
LLMBaseURL: os.Getenv("LLM_BASE_URL"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
Watchlist: tickers,
}
}
+122
View File
@@ -0,0 +1,122 @@
// Package llm calls an OpenAI-compatible chat endpoint for synthesis, report
// interrogation, and sentiment triage. Two models: LLM_MODEL_TRIAGE (cheap)
// and LLM_MODEL_SYNTH (strong), both overridden via env. No LLM call is on
// the critical detection path — rules and scores are computed locally first.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client talks to an OpenAI-compatible /chat/completions endpoint.
type Client struct {
baseURL string
apiKey string
http *http.Client
}
// New builds a client; baseURL is like https://api.openai.com/v1.
func New(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Available reports whether LLM calls are configured.
func (c *Client) Available() bool { return c.baseURL != "" && c.apiKey != "" }
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Complete sends one chat completion and returns the text content.
func (c *Client) Complete(ctx context.Context, model, system, user string, maxTokens int) (string, error) {
if !c.Available() {
return "", fmt.Errorf("llm: LLM_BASE_URL/LLM_API_KEY not configured")
}
if maxTokens <= 0 {
maxTokens = 800
}
body, _ := json.Marshal(map[string]any{
"model": model,
"messages": []chatMsg{{Role: "system", Content: system}, {Role: "user", Content: user}},
"max_tokens": maxTokens,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("llm: %w", err)
}
defer resp.Body.Close()
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("llm: decode: %w", err)
}
if out.Error != nil {
return "", fmt.Errorf("llm: %s", out.Error.Message)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("llm: empty response")
}
return out.Choices[0].Message.Content, nil
}
// SentimentTriage classifies one article; falls back to neutral on any error
// so sentiment never blocks the pipeline.
func (c *Client) SentimentTriage(ctx context.Context, model, title, body string) (string, float64) {
if !c.Available() {
return "neutral", 0.5
}
text, err := c.Complete(ctx, model,
`Classify Indonesian stock news as bullish, bearish, or neutral. Reply with exactly: <label> <confidence 0-1>. No other text.`,
"Title: "+title+"\nBody: "+head(body, 1500), 20)
if err != nil {
return "neutral", 0.5
}
parts := strings.Fields(strings.ToLower(text))
if len(parts) == 0 {
return "neutral", 0.5
}
label := parts[0]
if label != "bullish" && label != "bearish" {
label = "neutral"
}
var conf float64 = 0.6
if len(parts) > 1 {
fmt.Sscanf(parts[1], "%f", &conf)
}
if conf < 0 || conf > 1 {
conf = 0.6
}
return label, conf
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+74
View File
@@ -0,0 +1,74 @@
// Package model holds shared domain types: citations, snapshots, agent
// results. Every number in user-visible output carries a Citation.
// API response shapes live in internal/sectors; agent inputs in internal/agents.
package model
import "time"
// Citation pins a value to its source endpoint + snapshot timestamp.
// Stale (>1 session old) citations are rendered marked, never hidden.
type Citation struct {
Endpoint string `json:"endpoint"`
SnapshotAt string `json:"snapshot_at"`
Ticker string `json:"ticker,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// Value is a cited number or string shown to users.
type Value struct {
Label string `json:"label"`
Display string `json:"display"`
Citations []Citation `json:"citations"`
}
// Snapshot is one raw Sectors payload row.
type Snapshot struct {
ID int64
Ticker string
Date string
Source string
Payload string
FetchedAt time.Time
}
// AgentResult is the common output contract for A1..A7.
type AgentResult struct {
Agent string `json:"agent"`
Score float64 `json:"score"`
Summary string `json:"summary"`
Values []Value `json:"values"`
Flags []string `json:"flags"`
Citations []Citation `json:"citations"`
Extra map[string]any `json:"extra,omitempty"`
}
// Cite builds a Citation for one endpoint + snapshot date.
func Cite(endpoint, ticker, snapshotAt string) Citation {
return Citation{Endpoint: endpoint, SnapshotAt: snapshotAt, Ticker: ticker}
}
// StaleSession returns the date before which snapshots count as stale:
// anything older than the session containing ref (WIB trading day).
func StaleSession(ref time.Time) time.Time {
y, m, d := ref.Date()
return time.Date(y, m, d, 0, 0, 0, 0, ref.Location()).AddDate(0, 0, -1)
}
// MarkStale flags citations older than cutoff (one session back).
func MarkStale(citations []Citation, cutoff time.Time) []Citation {
out := make([]Citation, len(citations))
for i, c := range citations {
if t, err := time.Parse("2006-01-02", head10(c.SnapshotAt)); err == nil && t.Before(cutoff) {
c.Stale = true
}
out[i] = c
}
return out
}
func head10(s string) string {
if len(s) > 10 {
return s[:10]
}
return s
}
+91
View File
@@ -0,0 +1,91 @@
package reports
import (
"bytes"
"fmt"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
)
// todayStr stamps report generation time (UTC, date precision is enough).
func todayStr() string { return time.Now().UTC().Format("2006-01-02 15:04") }
// ToMarkdown renders the report as Markdown.
func (r Report) ToMarkdown() string {
var b strings.Builder
fmt.Fprintf(&b, "# %s — FlowSight Report (%s)\n\n", r.Ticker, r.GeneratedAt)
for _, s := range r.Sections {
fmt.Fprintf(&b, "## %s\n\n%s\n\n", s.Name, s.Body)
if len(s.Citations) > 0 {
b.WriteString("Citations:\n")
for _, c := range s.Citations {
fmt.Fprintf(&b, "- %s %s @ %s\n", c.Endpoint, c.Ticker, c.SnapshotAt)
}
b.WriteString("\n")
}
}
return b.String()
}
// ToHTML renders the report as a standalone page.
func (r Report) ToHTML() string {
var b strings.Builder
b.WriteString(`<!doctype html><html><head><meta charset="utf-8"><title>`)
b.WriteString(r.Ticker + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", r.Ticker, r.GeneratedAt)
for _, s := range r.Sections {
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", s.Name, s.Body)
if len(s.Citations) > 0 {
b.WriteString("<ul>")
for _, c := range s.Citations {
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", c.Endpoint, c.Ticker, c.SnapshotAt)
}
b.WriteString("</ul>")
}
}
b.WriteString("</body></html>")
return b.String()
}
// ToPDF renders the report server-side (pure Go, no system deps).
func (r Report) ToPDF() ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, r.Ticker+" — FlowSight Report")
pdf.Ln(12)
pdf.SetFont("Helvetica", "", 10)
pdf.Cell(0, 6, "Generated "+r.GeneratedAt)
pdf.Ln(8)
for _, s := range r.Sections {
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, s.Name)
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 10)
pdf.MultiCell(0, 5, latin1(s.Body), "", "", false)
if len(s.Citations) > 0 {
pdf.SetFont("Helvetica", "I", 8)
for _, c := range s.Citations {
pdf.MultiCell(0, 4, latin1(c.Endpoint+" "+c.Ticker+" @ "+c.SnapshotAt), "", "", false)
}
}
pdf.Ln(4)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// latin1 drops non-latin runes gofpdf core fonts cannot render.
func latin1(s string) string {
return strings.Map(func(r rune) rune {
if r > 255 {
return '?'
}
return r
}, s)
}
+115
View File
@@ -0,0 +1,115 @@
// Package reports builds the 7-section One-Click Report (overview, valuation,
// institutional, earnings, risk, calendar, recommendation) with citations[]
// per section, and exports PDF/HTML/MD/JSON. Every section populates from
// live/seed data; nothing renders empty without saying which snapshot is
// missing.
package reports
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"flowsight/internal/agents"
"flowsight/internal/model"
"flowsight/internal/store"
)
// Section is one report section with its own citations.
type Section struct {
Name string `json:"name"`
Body string `json:"body"`
Citations []model.Citation `json:"citations"`
}
// Report is the full 7-section payload.
type Report struct {
Ticker string `json:"ticker"`
GeneratedAt string `json:"generated_at"`
Sections []Section `json:"sections"`
Synthesis agents.Synthesis `json:"synthesis"`
AgentScores []model.AgentResult `json:"agent_scores"`
AllCitations []model.Citation `json:"citations"`
}
// Builder assembles reports from agent runs over stored snapshots.
type Builder struct {
DB *store.DB
Deps agents.Deps
Risk RiskFn
}
// RiskFn computes the portfolio-risk lines reused in the risk section.
type RiskFn func(ticker string) (concentration, beta string)
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the
// persisted report id for citation-scoped chat interrogation.
func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, int64, error) {
ticker = strings.ToUpper(ticker)
results := agents.RunAll(ctx, b.Deps, ticker)
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
byAgent := map[string]model.AgentResult{}
for _, r := range results {
byAgent[r.Agent] = r
}
sec := func(name, body string, cites []model.Citation) Section {
if strings.TrimSpace(body) == "" {
body = "no snapshots for this section yet"
}
return Section{Name: name, Body: body, Citations: cites}
}
sections := []Section{
sec("overview", overviewLine(ticker, byAgent), citesOf(byAgent, "sentiment", "technical")),
sec("valuation", byAgent["fundamental"].Summary, citesOf(byAgent, "fundamental")),
sec("institutional", institutionalLine(byAgent), citesOf(byAgent, "smart-money", "broker-intel")),
sec("earnings", byAgent["fundamental"].Summary+" | "+byAgent["catalyst"].Summary, citesOf(byAgent, "fundamental", "catalyst")),
sec("risk", riskLine(ticker, b.Risk), citesOf(byAgent, "technical")),
sec("calendar", byAgent["catalyst"].Summary, citesOf(byAgent, "catalyst")),
sec("recommendation", synth.Thesis, synth.Citations),
}
all := []model.Citation{}
for _, s := range sections {
all = append(all, s.Citations...)
}
all = model.MarkStale(all, model.StaleSession(time.Now()))
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth})
citesRaw, _ := json.Marshal(all)
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw))
if err != nil {
return Report{}, 0, err
}
return Report{
Ticker: ticker, GeneratedAt: todayStr(),
Sections: sections, Synthesis: synth,
AgentScores: results, AllCitations: all,
}, id, nil
}
func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation {
var out []model.Citation
for _, n := range names {
out = append(out, byAgent[n].Citations...)
}
return out
}
func overviewLine(ticker string, byAgent map[string]model.AgentResult) string {
return fmt.Sprintf("%s — sentiment %s; technical %s.",
ticker, byAgent["sentiment"].Summary, byAgent["technical"].Summary)
}
func institutionalLine(byAgent map[string]model.AgentResult) string {
return fmt.Sprintf("smart-money %s; broker-intel %s.",
byAgent["smart-money"].Summary, byAgent["broker-intel"].Summary)
}
func riskLine(ticker string, fn RiskFn) string {
if fn == nil {
return ticker + ": concentration/beta from /portfolio/risk (run screener first)."
}
c, b := fn(ticker)
return fmt.Sprintf("%s: %s; %s.", ticker, c, b)
}
+580
View File
@@ -0,0 +1,580 @@
package routines
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"flowsight/internal/model"
)
// Briefing (R1, 07:30 WIB daily): top-5 accumulation, foreign net per
// watchlist ticker, earnings + ex-div agenda next 7d, biggest 1d mover.
// Zero empty sections: every section renders, citing its snapshots.
func (e *Engine) Briefing(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
var cites []model.Citation
var b strings.Builder
fmt.Fprintf(&b, "FlowSight Morning Briefing — %s\n\n", t)
type acc struct {
ticker string
net float64
n int
}
var accs []acc
for _, tk := range e.tickers() {
nets, err := e.DB.NetBuySum5d(tk)
if err != nil || len(nets) == 0 {
continue
}
sum, n := 0.0, 0
for _, v := range nets {
if v > 0 {
n++
sum += v
}
}
accs = append(accs, acc{tk, sum, n})
cites = append(cites, model.Cite("v2/broker-summary/"+tk+"/top/", tk, t))
}
sort.Slice(accs, func(i, j int) bool { return accs[i].net > accs[j].net })
b.WriteString("Top accumulation:\n")
if len(accs) == 0 {
b.WriteString("- no accumulation snapshots yet\n")
} else {
for i, a := range accs {
if i >= 5 {
break
}
fmt.Fprintf(&b, "%d. %s net %s (%d brokers)\n", i+1, a.ticker, fmtIDR(a.net), a.n)
}
}
b.WriteString("\nForeign flow (last close):\n")
for _, tk := range e.tickers() {
dates, nets, err := e.DB.ForeignLast6(tk)
if err != nil || len(nets) == 0 {
fmt.Fprintf(&b, "- %s: no data\n", tk)
continue
}
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[len(dates)-1]))
fmt.Fprintf(&b, "- %s: %s on %s\n", tk, fmtIDR(nets[len(nets)-1]), dates[len(dates)-1])
}
e.agenda7d(&b, &cites)
moverOfDay(&b, &cites, e.DB)
out := capLines(b.String(), 25)
payload := map[string]any{"date": t, "text": out}
raw, _ := json.Marshal(payload)
_ = e.DB.SaveBriefing(t, string(raw), mustJSON(cites))
return out, cites, nil
}
// capLines enforces the ≤25-line delivery cap (header + rows survive).
func capLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) <= n {
return s
}
return strings.Join(lines[:n], "\n") + "\n… (capped at 25 lines)\n"
}
// agenda7d appends the earnings + ex-div agenda for the next 7 days from
// stored quarterly-dates and corporate-actions snapshots.
func (e *Engine) agenda7d(b *strings.Builder, cites *[]model.Citation) {
t := today()
b.WriteString("\nAgenda (7d):\n")
empty := true
for _, tk := range e.tickers() {
var qd []struct {
ReportDate string `json:"report_date"`
Date string `json:"date"`
}
if raw, d, err := e.DB.LatestSnapshot(tk, "quarterly-dates"); err == nil {
_ = json.Unmarshal([]byte(raw), &qd)
for _, q := range qd {
for _, rd := range []string{q.ReportDate, q.Date} {
if nd := nextAfter(rd, 90); nd != "" && withinDays(t, nd, 7) {
b.WriteString("- " + tk + " earnings ~" + nd + " (last " + rd[:10] + ")\n")
*cites = append(*cites, model.Cite("v2/company/get_quarterly_financial_dates/"+tk+"/", tk, d))
empty = false
}
}
}
}
var ca struct {
CorporateActions struct {
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
} `json:"corporate_actions"`
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
}
if raw, d, err := e.DB.LatestSnapshot(tk, "corporate-actions"); err == nil {
_ = json.Unmarshal([]byte(raw), &ca)
evs := append(ca.CorporateActions.UpcomingDividend, ca.UpcomingDividend...)
for _, ev := range evs {
if dt := firstStr(ev, "ex_date", "exDate", "date"); withinDays(t, dt, 7) {
b.WriteString("- " + tk + " ex-div " + dt[:10] + "\n")
*cites = append(*cites, model.Cite("v2/company/corporate-actions/"+tk+"/", tk, d))
empty = false
}
}
}
}
if empty {
b.WriteString("- no earnings/ex-div in the next 7d\n")
}
}
// moverOfDay appends the biggest 1d mover with a one-line news cause.
func moverOfDay(b *strings.Builder, cites *[]model.Citation, db interface {
LatestSnapshot(ticker, source string) (string, string, error)
NewsSince(ticker, since string) ([]map[string]any, error)
}) {
var mv struct {
TopGainers map[string][]struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Change float64 `json:"price_change"`
} `json:"top_gainers"`
}
raw, d, err := db.LatestSnapshot("IDX", "top-changes")
if err != nil {
b.WriteString("\nMover of the day: no snapshots yet\n")
return
}
_ = json.Unmarshal([]byte(raw), &mv)
best, bchg := "", 0.0
for _, rows := range mv.TopGainers {
for _, r := range rows {
if r.Change > bchg {
best, bchg = r.Symbol, r.Change
}
}
}
if best == "" {
b.WriteString("\nMover of the day: no gainers stored\n")
return
}
*cites = append(*cites, model.Cite("v2/companies/top-changes/", "IDX", d))
cause := "no linked headline"
tk := strings.ToUpper(strings.TrimSuffix(best, ".JK"))
if arts, err := db.NewsSince(tk, "2000-01-01"); err == nil && len(arts) > 0 {
if title, _ := arts[0]["title"].(string); title != "" {
cause = title
}
*cites = append(*cites, model.Cite("v2/news/", tk, d))
}
b.WriteString("\nMover of the day: " + best + " +" + pct1(bchg) + " — " + cause + "\n")
}
func nextAfter(date string, days int) string {
if len(date) < 10 {
return ""
}
t, err := time.Parse("2006-01-02", date[:10])
if err != nil {
return ""
}
return t.AddDate(0, 0, days).Format("2006-01-02")
}
func withinDays(today, date string, n int) bool {
if len(today) < 10 || len(date) < 10 {
return false
}
t0, e0 := time.Parse("2006-01-02", today[:10])
t1, e1 := time.Parse("2006-01-02", date[:10])
if e0 != nil || e1 != nil {
return false
}
d := int(t1.Sub(t0).Hours() / 24)
return d >= 0 && d <= n
}
func firstStr(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 && len(s) >= 10 {
return s
}
}
}
}
return ""
}
func pct1(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v*100), "0"), ".") + "%"
}
// Radar (R2): accumulation rule hits across the watchlist. First-fire
// only per (ticker, day): tickers with an accumulation event already
// recorded today are skipped.
func (e *Engine) Radar(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
var cites []model.Citation
var lines []string
fired := e.firedToday("accumulation", t)
for _, tk := range e.tickers() {
if fired[tk] {
continue
}
nets, err := e.DB.NetBuySum5d(tk)
if err != nil || len(nets) == 0 {
continue
}
n, sum := 0, 0.0
for _, v := range nets {
if v > 0 {
n++
sum += v
}
}
volMult := 0.0
if vols, _, err := e.DB.DailyVolumes(tk, 21); err == nil && len(vols) > 0 {
if n := len(vols); n >= 2 {
if a := avgF(vols[:n-1]); a > 0 {
volMult = vols[n-1] / a
}
}
}
if n >= 3 && volMult > 1.5 {
lines = append(lines, fmt.Sprintf("%s: %d brokers net %s, vol %.1fx — /report/%s",
tk, n, fmtIDR(sum), volMult, tk))
cites = append(cites, model.Cite("v2/broker-summary/"+tk+"/top/", tk, t))
}
}
if len(lines) == 0 {
return "radar: no accumulation signals this cycle", cites, nil
}
return "Accumulation Radar:\n- " + strings.Join(lines, "\n- "), cites, nil
}
// ReversalWatch (R3): 5d cumulative one way, last day the other way at
// >2x the trailing 5d daily average (both directions, per ROUTINES.md).
func (e *Engine) ReversalWatch(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
var cites []model.Citation
var lines []string
for _, tk := range e.tickers() {
dates, nets, err := e.DB.ForeignLast6(tk)
if err != nil || len(nets) < 6 {
continue
}
sum5, absAvg := 0.0, 0.0
for _, v := range nets[:5] {
sum5 += v
absAvg += absF(v)
}
absAvg /= 5
last := nets[5]
switch {
case absAvg > 0 && sum5 < 0 && last > 0 && last > 2*absAvg:
lines = append(lines, fmt.Sprintf("%s: first inflow %s after 5 selling days (%s)",
tk, fmtIDR(last), dates[5]))
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[5]))
case absAvg > 0 && sum5 > 0 && last < 0 && -last > 2*absAvg:
lines = append(lines, fmt.Sprintf("%s: outflow %s after 5 buying days (%s)",
tk, fmtIDR(-last), dates[5]))
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[5]))
}
}
if len(lines) == 0 {
return "reversal watch: no flips this cycle", cites, nil
}
return "Foreign Reversal Watch:\n- " + strings.Join(lines, "\n- "), cites, nil
}
// InsiderTape (R4): buy volume > 2x 30d avg, or >=3 distinct insiders in
// 7d (per ROUTINES.md rule 3).
func (e *Engine) InsiderTape(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
var cites []model.Citation
var lines []string
for _, tk := range e.tickers() {
avg30 := e.DB.FilingAvg30(tk)
last := e.DB.LatestBuyVolume(tk)
cluster := e.DB.DistinctInsiders7d(tk)
switch {
case avg30 > 0 && last > 2*avg30:
lines = append(lines, fmt.Sprintf("%s: insider buy %.0f vs 30d avg %.0f (%.1fx)",
tk, last, avg30, last/avg30))
cites = append(cites, model.Cite("v2/filings/", tk, t))
case cluster >= 3:
lines = append(lines, fmt.Sprintf("%s: %d distinct insiders buying in 7d", tk, cluster))
cites = append(cites, model.Cite("v2/filings/", tk, t))
}
}
if len(lines) == 0 {
return "insider tape: no filings in window", cites, nil
}
return "Insider Tape:\n- " + strings.Join(lines, "\n- "), cites, nil
}
// firedToday returns tickers with an event whose message contains rule
// already recorded today (first-fire guard).
func (e *Engine) firedToday(rule, today string) map[string]bool {
out := map[string]bool{}
evts, err := e.DB.AlertEventsSince(today, "", 100)
if err != nil {
return out
}
for _, ev := range evts {
msg, _ := ev["message"].(string)
if tk, _ := ev["ticker"].(string); ev["date"] == today && strings.Contains(msg, rule) {
out[tk] = true
}
}
return out
}
// EarningsCountdown (R5): fires at H-7/H-3/H-1 from stored quarterly dates,
// with the 8-quarter revenue/earnings mini-trend attached.
func (e *Engine) EarningsCountdown(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
var cites []model.Citation
var lines []string
for _, tk := range e.tickers() {
raw, d, err := e.DB.LatestSnapshot(tk, "quarterly-dates")
if err != nil {
continue
}
var qd []struct {
ReportDate string `json:"report_date"`
Date string `json:"date"`
}
_ = json.Unmarshal([]byte(raw), &qd)
for _, q := range qd {
for _, rd := range []string{q.ReportDate, q.Date} {
next := nextAfter(rd, 90)
if next == "" {
continue
}
h := daysUntil(t, next)
if h == 7 || h == 3 || h == 1 {
lines = append(lines, fmt.Sprintf("%s earnings H-%d (%s)%s", tk, h, next, e.trend8Q(tk)))
cites = append(cites, model.Cite("v2/company/get_quarterly_financial_dates/"+tk+"/", tk, d))
}
}
}
}
if len(lines) == 0 {
return "earnings countdown: nothing at H-7/H-3/H-1 today", cites, nil
}
return "Earnings Countdown:\n- " + strings.Join(lines, "\n- "), cites, nil
}
// trend8Q renders the 8-quarter revenue/earnings mini-trend for one ticker.
func (e *Engine) trend8Q(tk string) string {
raw, _, err := e.DB.LatestSnapshot(tk, "financials-quarterly")
if err != nil {
return ""
}
var rows []struct {
Revenue *float64 `json:"revenue"`
Earnings *float64 `json:"earnings"`
}
if json.Unmarshal([]byte(raw), &rows) != nil || len(rows) < 2 {
return ""
}
if len(rows) > 8 {
rows = rows[len(rows)-8:]
}
f := func(p *float64) float64 {
if p == nil {
return 0
}
return *p
}
rev, earn := "", ""
if a, b := f(rows[0].Revenue), f(rows[len(rows)-1].Revenue); a > 0 {
rev = fmt.Sprintf(" rev %+.0f%%", (b-a)/a*100)
}
if a, b := f(rows[0].Earnings), f(rows[len(rows)-1].Earnings); a != 0 {
earn = fmt.Sprintf(" earn %+.0f%%", (b-a)/absF(a)*100)
}
return " [" + strings.TrimSpace(rev+earn) + "]"
}
// DividendCalendar (R6): fires at H-14/H-3 with DPS, est. yield, payout flag.
func (e *Engine) DividendCalendar(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
var cites []model.Citation
var lines []string
for _, tk := range e.tickers() {
raw, d, err := e.DB.LatestSnapshot(tk, "corporate-actions")
if err != nil {
continue
}
var ca struct {
CorporateActions struct {
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
} `json:"corporate_actions"`
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
}
_ = json.Unmarshal([]byte(raw), &ca)
evs := append(ca.CorporateActions.UpcomingDividend, ca.UpcomingDividend...)
closePx, _, _ := e.DB.LatestClose(tk)
payout := e.payoutOf(tk)
for _, ev := range evs {
dt := firstStr(ev, "ex_date", "exDate", "date")
if dt == "" {
continue
}
h := daysUntil(t, dt[:10])
if h != 14 && h != 3 {
continue
}
dps := numOf(ev, "dividend_per_share", "dps", "cash_dividend")
y := ""
if dps > 0 && closePx > 0 {
y = fmt.Sprintf(" DPS %.0f yield %.1f%%", dps, dps/closePx*100)
}
flag := ""
if payout > 0.8 {
flag = " aggressive-payout"
}
lines = append(lines, fmt.Sprintf("%s ex-div H-%d (%s)%s%s", tk, h, dt[:10], y, flag))
cites = append(cites, model.Cite("v2/company/corporate-actions/"+tk+"/", tk, d))
}
}
if len(lines) == 0 {
return "dividend calendar: nothing at H-14/H-3 today", cites, nil
}
return "Dividend Calendar:\n- " + strings.Join(lines, "\n- "), cites, nil
}
// payoutOf reads the dividend payout ratio from the stored company report.
func (e *Engine) payoutOf(tk string) float64 {
raw, _, err := e.DB.LatestSnapshot(tk, "company-report")
if err != nil {
return 0
}
var rep map[string]any
_ = json.Unmarshal([]byte(raw), &rep)
p := numOf(rep, "payout_ratio", "dividend_payout")
if p > 1 {
p /= 100
}
return p
}
// numOf digs the first present numeric key (case-insensitive, nested).
func numOf(m map[string]any, keys ...string) float64 {
for _, k := range keys {
if v, ok := findNum(m, k); ok {
return v
}
}
return 0
}
func findNum(m map[string]any, key string) (float64, bool) {
for k, v := range m {
if strings.EqualFold(k, key) {
if f, ok := asFloat(v); ok {
return f, true
}
}
if sub, ok := v.(map[string]any); ok {
if f, ok := findNum(sub, key); ok {
return f, true
}
}
}
return 0, false
}
func asFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
default:
return 0, false
}
}
// daysUntil returns whole days from today to date (negative if past).
func daysUntil(today, date string) int {
if len(today) < 10 || len(date) < 10 {
return -999
}
t0, e0 := time.Parse("2006-01-02", today[:10])
t1, e1 := time.Parse("2006-01-02", date[:10])
if e0 != nil || e1 != nil {
return -999
}
return int(t1.Sub(t0).Hours() / 24)
}
// WeekendReview (R7, Sat 09:00): week movers + drivers + open risks + agenda.
func (e *Engine) WeekendReview(ctx context.Context) (string, []model.Citation, error) {
_ = ctx
t := today()
text, cites, err := e.Briefing(ctx)
if err != nil {
return "", cites, err
}
out := "Weekend Review — week of " + t + "\n\n" + text
return out, cites, nil
}
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
}
func avgF(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
s := 0.0
for _, x := range xs {
s += x
}
return s / float64(len(xs))
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func absF(v float64) float64 {
if v < 0 {
return -v
}
return v
}
+161
View File
@@ -0,0 +1,161 @@
// Package routines turns agent output into scheduled deliveries (R1..R7).
// Each run records a routine_runs row; each delivery carries citations.
// Routines read snapshots, never live-fetch inside delivery.
package routines
import (
"context"
"encoding/json"
"fmt"
"time"
"flowsight/internal/alerts"
"flowsight/internal/model"
"flowsight/internal/store"
)
// Routine types R1..R7.
const (
RBriefing = "morning-briefing"
RRadar = "accumulation-radar"
RReversal = "foreign-reversal"
RInsider = "insider-tape"
REarnings = "earnings-countdown"
RDividend = "dividend-calendar"
RWeekend = "weekend-review"
)
// KnownType reports whether typ is one of R1..R7.
func KnownType(typ string) bool {
switch typ {
case RBriefing, RRadar, RReversal, RInsider, REarnings, RDividend, RWeekend:
return true
}
return false
}
// DefaultSchedule returns the spec cron for a routine type (WIB).
func DefaultSchedule(typ string) string {
switch typ {
case RBriefing:
return "30 7 * * *" // 07:30 daily
case RRadar, RReversal, RInsider:
return "*/30 9-16 * * 1-5" // 30-min market hours
case REarnings, RDividend:
return "0 8 * * *" // 08:00 daily
case RWeekend:
return "0 9 * * 6" // Sat 09:00
default:
return "0 8 * * *"
}
}
// Engine runs routines on demand (called by the scheduler) and by hand.
type Engine struct {
DB *store.DB
Notifier *alerts.Notifier
UserKey string
// Publish, when set, receives ("activity", text) per completed run so
// the dashboard SSE feed shows routine activity live.
Publish func(channel, data string)
}
// Run executes one routine row and records the run.
// Prefer RunFor: it scopes the run to the row owner (watchlist + delivery).
func (e *Engine) Run(ctx context.Context, r store.Routine) (string, error) {
return e.RunFor(ctx, r.UserKey, r)
}
// RunFor executes one routine row owned by owner: tickers come from the
// owner's watchlist and delivery fans out to the owner's destinations.
func (e *Engine) RunFor(ctx context.Context, owner string, r store.Routine) (string, error) {
// Scope this run to the owner without mutating shared engine state.
scoped := *e
if owner != "" {
scoped.UserKey = owner
}
se := &scoped
var (
payload string
cites []model.Citation
err error
)
switch r.Type {
case RBriefing:
payload, cites, err = se.Briefing(ctx)
case RRadar:
payload, cites, err = se.Radar(ctx)
case RReversal:
payload, cites, err = se.ReversalWatch(ctx)
case RInsider:
payload, cites, err = se.InsiderTape(ctx)
case REarnings:
payload, cites, err = se.EarningsCountdown(ctx)
case RDividend:
payload, cites, err = se.DividendCalendar(ctx)
case RWeekend:
payload, cites, err = se.WeekendReview(ctx)
default:
err = fmt.Errorf("routines: unknown type %q", r.Type)
}
status := "ok"
if err != nil {
status = "error"
payload = err.Error()
}
cc, _ := json.Marshal(cites)
if _, rerr := se.DB.RecordRun(r.ID, status, summarize(payload, cc), 0, scoped.UserKey); rerr != nil && err == nil {
err = rerr
}
if err == nil && status == "ok" {
_ = se.deliver(ctx, r, payload, cites)
}
if e.Publish != nil {
e.Publish("activity", r.Type+" "+status+": "+head(payload, 160))
}
return payload, err
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func summarize(payload string, cites []byte) string {
return `{"payload":` + strconv(payload) + `,"citations":` + string(cites) + `}`
}
func strconv(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// deliver pushes the payload to the routine's channels via the notifier,
// WITH citations (alert cards render context + citations + report link).
// Delivery is scoped to the routine owner: the owner's enabled destinations
// (managed at /alerts → Push destinations, i.e. POST /api/destinations)
// plus the server env fallback. With no targets anywhere it is record-only.
func (e *Engine) deliver(ctx context.Context, r store.Routine, payload string, cites []model.Citation) error {
if len(r.Channels) == 0 || e.Notifier.DryRunFor(e.UserKey) {
return nil
}
cc, _ := json.Marshal(cites)
f := alerts.Finding{Rule: r.Type, Ticker: "IDX", Message: payload, Citations: cites}
return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc))
}
// tickers returns the user's watchlist (never empty: falls back to demo set).
func (e *Engine) tickers() []string {
wl, err := e.DB.Watchlist(e.UserKey)
if err == nil && len(wl) > 0 {
return wl
}
return []string{"BBCA"}
}
// todayStr is overridable in tests via Now.
var nowFn = time.Now
func today() string { return nowFn().Format("2006-01-02") }
@@ -0,0 +1,98 @@
package routines
import (
"context"
"strings"
"testing"
"flowsight/internal/alerts"
"flowsight/internal/store"
)
func seedEngine(t *testing.T) *Engine {
t.Helper()
db, err := store.Open(t.TempDir() + "/routines.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
t.Fatal(err)
}
return &Engine{DB: db, Notifier: alerts.NewNotifier("", "", ""), UserKey: "demo"}
}
// Briefing generates from seed with zero empty sections.
func TestBriefing(t *testing.T) {
e := seedEngine(t)
text, cites, err := e.Briefing(context.Background())
if err != nil {
t.Fatal(err)
}
for _, sec := range []string{"Top accumulation", "Foreign flow", "Agenda"} {
if !strings.Contains(text, sec) {
t.Fatalf("missing section %q", sec)
}
}
if len(cites) == 0 {
t.Fatal("briefing has no citations")
}
}
// Radar fires on the accumulation fixture.
func TestRadar(t *testing.T) {
e := seedEngine(t)
text, _, err := e.Radar(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(text, "BBCA") {
t.Fatalf("radar missed BBCA: %s", text)
}
}
// Accuracy resolution math: weights follow the ledger.
func TestAccuracyWeight(t *testing.T) {
e := seedEngine(t)
if w := e.DB.AccuracyWeight("smart-money"); w != 0.5 {
t.Fatalf("default weight = %v, want 0.5", w)
}
for i := 0; i < 10; i++ {
_ = e.DB.RecordPrediction("smart-money", "BBCA", "BUY")
}
rows, _ := e.DB.DueForResolution()
_ = rows
}
// Briefing carries agenda + mover sections and respects the 25-line cap.
func TestBriefingAgendaMoverCap(t *testing.T) {
e := seedEngine(t)
text, cites, err := e.Briefing(context.Background())
if err != nil {
t.Fatal(err)
}
for _, sec := range []string{"Top accumulation", "Foreign flow", "Agenda", "Mover of the day"} {
if !strings.Contains(text, sec) {
t.Fatalf("missing section %q:\n%s", sec, text)
}
}
if n := len(strings.Split(strings.TrimRight(text, "\n"), "\n")); n > 25 {
t.Fatalf("briefing = %d lines, want ≤25", n)
}
if len(cites) == 0 {
t.Fatal("briefing has no citations")
}
}
// R5/R6 run without error on seed (windows simply miss today).
func TestCountdownsRun(t *testing.T) {
e := seedEngine(t)
rows, _ := e.DB.ListRoutines("demo")
_ = rows
if _, _, err := e.EarningsCountdown(context.Background()); err != nil {
t.Fatal(err)
}
if _, _, err := e.DividendCalendar(context.Background()); err != nil {
t.Fatal(err)
}
}
+409
View File
@@ -0,0 +1,409 @@
// Package scheduler wires the 30-min ingestion cycle (09:00-16:00 WIB market
// hours) plus per-routine cron dispatch into one in-process robfig/cron.
// Order per cycle: reference cache -> universe sweep -> market context ->
// per-watchlist depth -> incremental events -> quarterly freshness ->
// rule evaluation -> routine dispatch. Credit spend aborts past the cap.
package scheduler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/url"
"time"
"github.com/robfig/cron/v3"
"flowsight/internal/alerts"
"flowsight/internal/config"
"flowsight/internal/routines"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// Scheduler owns ingestion + routine dispatch.
type Scheduler struct {
Cfg config.Config
DB *store.DB
Cache *store.Cache
Sectors *sectors.Client
Notifier *alerts.Notifier
Engine *routines.Engine
// Publish, when set, feeds routine activity into the SSE hub.
Publish func(channel, data string)
cron *cron.Cron
lastRun time.Time
ok bool
}
// New wires dependencies; the sectors OnSpend hook persists credit_ledger rows.
func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client) *Scheduler {
sch := &Scheduler{Cfg: cfg, DB: db, Cache: cache, Sectors: s,
Notifier: alerts.NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID, cfg.DiscordWebhookURL),
}
sch.Notifier.Targets = func(owner string) []alerts.Target {
dests, err := db.ListEnabledDestinations(owner)
if err != nil {
return nil
}
var out []alerts.Target
for _, d := range dests {
switch d.Kind {
case store.DestTelegram:
if d.BotToken != "" && d.ChatID != "" {
out = append(out, alerts.Target{TelegramToken: d.BotToken, TelegramChatID: d.ChatID})
}
case store.DestDiscord:
if d.WebhookURL != "" {
out = append(out, alerts.Target{DiscordURL: d.WebhookURL})
}
}
}
return out
}
sch.Engine = &routines.Engine{DB: db, Notifier: sch.Notifier, UserKey: cfg.DemoUserKey}
sch.Engine.Publish = func(channel, data string) {
if sch.Publish != nil {
sch.Publish(channel, data)
}
}
s.OnSpend(func(endpoint string, calls, credits int) {
db.AddSpend(time.Now().Format("2006-01-02"), endpoint, calls, credits)
})
return sch
}
// Start registers ingestion + routine crons (WIB = UTC+7) and begins ticking.
func (s *Scheduler) Start() {
c := cron.New(cron.WithLocation(wib()))
// 30-min ingestion, weekdays 09:00-16:00 WIB.
_, _ = c.AddFunc("*/30 9-16 * * 1-5", func() { s.RunCycle(context.Background()) })
// Routines: briefing 07:30 daily, countdowns 08:00, weekend Sat 09:00,
// watchtower R2..R4 every 30 min in market hours.
_, _ = c.AddFunc("30 7 * * *", func() { s.runType(context.Background(), routines.RBriefing) })
_, _ = c.AddFunc("*/30 9-16 * * 1-5", func() {
s.runType(context.Background(), routines.RRadar)
s.runType(context.Background(), routines.RReversal)
s.runType(context.Background(), routines.RInsider)
})
_, _ = c.AddFunc("0 8 * * *", func() { s.runType(context.Background(), routines.REarnings) })
_, _ = c.AddFunc("5 8 * * *", func() { s.runType(context.Background(), routines.RDividend) })
// Accuracy resolution on its own daily cadence (was piggybacked on RunCycle).
_, _ = c.AddFunc("15 6 * * *", func() {
if n, err := s.DB.ResolveDue(); err != nil {
log.Printf("scheduler: resolve: %v", err)
} else if n > 0 {
log.Printf("scheduler: resolved %d predictions", n)
}
})
// Snapshot retention: weekly prune of rows older than 180d (keeps newest
// per ticker/source for seeds and last-good fallback).
_, _ = c.AddFunc("30 6 * * 0", func() {
cutoff := time.Now().AddDate(0, 0, -180).Format("2006-01-02")
if n, err := s.DB.PruneSnapshots(cutoff); err != nil {
log.Printf("scheduler: prune: %v", err)
} else if n > 0 {
log.Printf("scheduler: pruned %d snapshots older than %s", n, cutoff)
}
})
_, _ = c.AddFunc("0 9 * * 6", func() { s.runType(context.Background(), routines.RWeekend) })
s.cron = c
c.Start()
s.ok = true
}
// Stop halts all crons.
func (s *Scheduler) Stop() {
if s.cron != nil {
s.cron.Stop()
}
s.ok = false
}
// Status reports scheduler health for /api/health.
func (s *Scheduler) Status() (lastCycle string, ok bool) {
if s.lastRun.IsZero() {
return "", s.ok
}
return s.lastRun.UTC().Format(time.RFC3339), s.ok
}
func wib() *time.Location {
return time.FixedZone("WIB", 7*3600)
}
// RunCycle executes one full ingestion cycle. Offline (no key) it returns
// early after marking scheduler health — seed data keeps the demo alive.
func (s *Scheduler) RunCycle(ctx context.Context) error {
if !s.Cfg.HasSectorsKey() {
s.lastRun = time.Now()
return nil
}
today := time.Now().Format("2006-01-02")
before := s.DB.CreditsToday(today)
guard := func() error {
if spent := s.DB.CreditsToday(today) - before; spent > s.Cfg.CreditCapPerCycle {
return fmt.Errorf("scheduler: credit cap %d exceeded (spent %d), aborting cycle",
s.Cfg.CreditCapPerCycle, spent)
}
return nil
}
if err := s.reference(ctx); err != nil {
log.Printf("scheduler: reference: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.universe(ctx); err != nil {
log.Printf("scheduler: universe: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.market(ctx); err != nil {
log.Printf("scheduler: market: %v", err)
}
if err := guard(); err != nil {
return err
}
for _, t := range s.watchlist() {
if err := s.tickerDepth(ctx, t); err != nil {
log.Printf("scheduler: depth %s: %v", t, err)
}
if err := guard(); err != nil {
return err
}
}
if err := s.events(ctx); err != nil {
log.Printf("scheduler: events: %v", err)
}
if err := s.freshness(ctx); err != nil {
log.Printf("scheduler: freshness: %v", err)
}
s.evaluate(ctx)
s.lastRun = time.Now()
return nil
}
func (s *Scheduler) watchlist() []string {
return s.watchlistFor(s.Cfg.DemoUserKey)
}
// watchlistFor resolves one owner's watchlist (demo seed fallback kept).
func (s *Scheduler) watchlistFor(owner string) []string {
wl, err := s.DB.Watchlist(owner)
if err == nil && len(wl) > 0 {
return wl
}
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
return s.Cfg.Watchlist
}
return []string{"BBCA"}
}
// reference refreshes registry/taxonomy caches (24h TTL, fallback last good).
func (s *Scheduler) reference(ctx context.Context) error {
if raw := s.Cache.Get(ctx, "registry"); raw != "" {
return nil // fresh enough; TTL governs refresh
}
reg, err := s.Sectors.BrokersRegistry(ctx, "", "")
if err != nil {
if raw, _, lerr := s.DB.LatestSnapshot("IDX", "brokers-registry"); lerr == nil && raw != "" {
var cached []sectors.BrokerRegistryRow
if jerr := json.Unmarshal([]byte(raw), &cached); jerr == nil && len(cached) > 0 {
s.Cache.SetJSON(ctx, "registry", cached, 24*time.Hour)
return nil
}
}
return err
}
s.Cache.SetJSON(ctx, "registry", reg, 24*time.Hour)
if raw, err := json.Marshal(reg); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "brokers-registry", string(raw))
}
for _, kind := range []string{"subsectors", "industries", "subindustries", "tags"} {
if rows, err := s.Sectors.Taxonomy(ctx, kind); err == nil {
s.Cache.SetJSON(ctx, "tax:"+kind, rows, 24*time.Hour)
}
}
return nil
}
// universe sweeps close/ pages for the latest trading day.
func (s *Scheduler) universe(ctx context.Context) error {
offset := 0
for page := 0; page < 12; page++ {
rows, total, err := s.Sectors.ClosePage(ctx, "", 30, offset)
if err != nil {
return err
}
if raw, err := json.Marshal(rows); err == nil && len(rows) > 0 {
_ = s.DB.SaveSnapshot("IDX", rows[0].Date, "close", string(raw))
}
offset += len(rows)
if offset >= total || len(rows) == 0 {
break
}
}
return nil
}
// market pulls top-changes (1 class x 2 periods), most-traded, idx-total,
// brokers/top — the cheap context block of the cycle.
func (s *Scheduler) market(ctx context.Context) error {
if g, l, err := s.Sectors.TopChanges(ctx,
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 5); err == nil {
if raw, err := json.Marshal(map[string]any{"top_gainers": g, "top_losers": l}); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "top-changes", string(raw))
}
} else {
return err
}
if mt, err := s.Sectors.MostTraded(ctx, "", "", "", 5); err == nil {
if raw, err := json.Marshal(mt); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "most-traded", string(raw))
}
}
if it, err := s.Sectors.IdxTotal(ctx, "", ""); err == nil {
if raw, err := json.Marshal(it); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "idx-total", string(raw))
}
}
if bt, err := s.Sectors.BrokersTop(ctx, "", "net", 20, "", ""); err == nil {
if raw, err := json.Marshal(map[string]any{"results": bt}); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "brokers-top", string(raw))
}
}
return nil
}
// tickerDepth pulls broker-summary/top + foreign-flow + daily per ticker.
func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
end := time.Now().Format("2006-01-02")
start5 := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
if top, err := s.Sectors.BrokerSummaryTop(ctx, ticker, start5, end, 10, "", ""); err == nil {
if raw, err := json.Marshal(top); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "broker-summary-top", string(raw))
for _, b := range top.TopBuyers {
_ = s.DB.UpsertBrokerActivity(b.BrokerCode, ticker, end,
float64(b.BuyIDR), float64(b.SellIDR), float64(b.NetIDR), 0, 0, 0)
}
for _, x := range top.TopSellers {
_ = s.DB.UpsertBrokerActivity(x.BrokerCode, ticker, end,
float64(x.BuyIDR), float64(x.SellIDR), float64(x.NetIDR), 0, 0, 0)
}
}
} else {
return err
}
if ff, err := s.Sectors.ForeignFlow(ctx, ticker,
time.Now().AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(ff); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
_ = s.DB.UpsertForeignFlow(ticker, d.Date, float64(d.NetForeignInflow))
}
}
}
if bars, err := s.Sectors.Daily(ctx, ticker,
time.Now().AddDate(0, 0, -30).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(bars); err == nil && len(bars) > 0 {
_ = s.DB.SaveSnapshot(ticker, end, "daily", string(raw))
}
}
return nil
}
// events polls news/filings/suspensions incrementally via meta cursors.
func (s *Scheduler) events(ctx context.Context) error {
today := time.Now().Format("2006-01-02")
newsSince := s.DB.GetMeta("news_since")
if news, err := s.Sectors.News(ctx, "", newsSince, today, "", 20); err == nil {
if raw, err := json.Marshal(map[string]any{"results": news}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "news", string(raw))
}
s.DB.SetMeta("news_since", today)
}
filSince := s.DB.GetMeta("filings_since")
if fils, err := s.Sectors.Filings(ctx, "", "", "", filSince, today); err == nil {
if raw, err := json.Marshal(map[string]any{"results": fils}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "filings", string(raw))
}
s.DB.SetMeta("filings_since", today)
}
if susp, err := s.Sectors.Suspensions(ctx, "", newsSince, today); err == nil {
if raw, err := json.Marshal(map[string]any{"results": susp}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "suspensions", string(raw))
}
}
return nil
}
// freshness polls quarterly-dates with since= (never a full sweep).
func (s *Scheduler) freshness(ctx context.Context) error {
since := s.DB.GetMeta("quarterly_since")
rows, err := s.Sectors.QuarterlyDatesSince(ctx, since, 30, 0)
if err != nil {
return err
}
if len(rows) > 0 {
if raw, err := json.Marshal(rows); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "quarterly-dates", string(raw))
}
s.DB.SetMeta("quarterly_since", time.Now().Format("2006-01-02"))
}
return nil
}
// evaluate runs rule evaluation for every user that owns alerts, each
// against that owner's own watchlist and destinations.
func (s *Scheduler) evaluate(ctx context.Context) {
for _, owner := range s.alertOwners() {
alertsList, err := s.DB.ListAlerts(owner)
if err != nil {
continue
}
if len(alertsList) == 0 {
continue
}
wl := s.watchlistFor(owner)
for _, a := range alertsList {
alerts.EvaluateFor(ctx, s.DB, s.Notifier, a.ID, owner, a.Rule, wl)
}
}
}
// runType executes all enabled routines of one type, per owning user: each
// run reads that user's watchlist and pushes to that user's destinations.
func (s *Scheduler) runType(ctx context.Context, typ string) {
for _, owner := range s.routineOwners() {
rows, err := s.DB.ListRoutines(owner)
if err != nil {
continue
}
for _, r := range rows {
if r.Type == typ && r.Enabled {
s.Engine.RunFor(ctx, owner, r)
}
}
}
}
// alertOwners lists users owning at least one alert.
func (s *Scheduler) alertOwners() []string {
return s.DB.DistinctUsers("alerts")
}
// routineOwners lists users owning at least one enabled routine.
func (s *Scheduler) routineOwners() []string {
owners := s.DB.DistinctUsers("routines")
if len(owners) == 0 {
return []string{s.Cfg.DemoUserKey}
}
return owners
}
// TriggerCycle runs one cycle synchronously (used by tests and the
// ?force=1 health probe); q carries no Sectors params.
var _ = url.Values{}
+276
View File
@@ -0,0 +1,276 @@
// Package sectors wraps the Sectors API v2 with retry, credit counting, and
// parameter-narrowing guards. Every outbound Sectors call in FlowSight goes
// through SectorsClient — no raw HTTP to api.sectors.app elsewhere.
//
// Auth: `Authorization: <raw-key>` from env SECTORS_API_KEY. v1 is gone (410);
// only v2 paths are used.
package sectors
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrNoAPIKey is returned when no SECTORS_API_KEY is configured (offline mode).
var ErrNoAPIKey = errors.New("sectors: SECTORS_API_KEY not set (offline/seed mode)")
// ErrV1Gone is returned if a v1 path is ever requested (v1 returns 410).
var ErrV1Gone = errors.New("sectors: v1 API discontinued (410 Gone), use v2 only")
// Spend tracks credit usage per endpoint for one day.
type Spend struct {
Calls int
Credits int
}
// Client is a credit-counting Sectors v2 HTTP client.
type Client struct {
baseURL string
apiKey string
http *http.Client
mu sync.Mutex
day string
spend map[string]*Spend
onSpend func(endpoint string, calls, credits int)
}
// New builds a client. baseURL should end with a slash.
func New(baseURL, apiKey string) *Client {
if !strings.HasSuffix(baseURL, "/") {
baseURL += "/"
}
tr := &http.Transport{
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
}
return &Client{
baseURL: baseURL,
apiKey: apiKey,
http: &http.Client{Transport: tr, Timeout: 30 * time.Second},
day: time.Now().Format("2006-01-02"),
spend: map[string]*Spend{},
}
}
// OnSpend registers a callback fired after every counted call (used by the
// scheduler to persist rows into credit_ledger).
func (c *Client) OnSpend(fn func(endpoint string, calls, credits int)) {
c.mu.Lock()
defer c.mu.Unlock()
c.onSpend = fn
}
// SpendToday returns a copy of today's per-endpoint spend.
func (c *Client) SpendToday() map[string]Spend {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]Spend, len(c.spend))
for k, v := range c.spend {
out[k] = *v
}
return out
}
// CreditsToday returns total credits spent today.
func (c *Client) CreditsToday() int {
total := 0
for _, s := range c.SpendToday() {
total += s.Credits
}
return total
}
func (c *Client) count(endpoint string, calls, credits int) {
c.mu.Lock()
today := time.Now().Format("2006-01-02")
if today != c.day {
c.day, c.spend = today, map[string]*Spend{}
}
s := c.spend[endpoint]
if s == nil {
s = &Spend{}
c.spend[endpoint] = s
}
s.Calls += calls
s.Credits += credits
fn := c.onSpend
c.mu.Unlock()
if fn != nil {
fn(endpoint, calls, credits)
}
}
// costFor implements the credit-cost table from docs/API-REFERENCE.md.
func costFor(path string, q url.Values) int {
p := strings.Trim(path, "/")
switch {
case p == "v2/companies/top-changes" || p == "companies/top-changes":
classes := splitCSV(q.Get("classifications"))
periods := splitCSV(q.Get("periods"))
if len(classes) == 0 {
classes = []string{"a", "b"} // default is expensive: 2 classes
}
if len(periods) == 0 {
periods = []string{"a", "b", "c", "d", "e"} // default: 5 periods
}
return len(classes) * len(periods)
case strings.HasPrefix(p, "v2/company/report") || strings.HasPrefix(p, "company/report"):
if s := splitCSV(q.Get("sections")); len(s) > 0 {
return len(s)
}
return 8 // default all sections
case strings.HasPrefix(p, "v2/subsector/report") || strings.HasPrefix(p, "subsector/report"):
if s := splitCSV(q.Get("sections")); len(s) > 0 {
return len(s)
}
return 6
case strings.HasPrefix(p, "v2/financials/quarterly") || strings.HasPrefix(p, "financials/quarterly"):
if n := atoi(q.Get("n_quarters"), 1); n > 0 {
return n
}
return 1
case p == "v2/most-traded" || p == "most-traded",
p == "v2/brokers/top" || p == "brokers/top",
strings.HasSuffix(p, "/top") && strings.Contains(p, "broker-activity"),
strings.HasSuffix(p, "/top") && strings.Contains(p, "broker-summary"):
return 2
default:
return 1
}
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func atoi(s string, def int) int {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n <= 0 {
return def
}
return n
}
// maxBrokerWindowDays and maxSeriesDays encode the API window limits.
const (
maxBrokerWindowDays = 14
maxSeriesDays = 90
)
// ClampBrokerWindow clamps a date range to the 14-day broker-endpoint limit.
func ClampBrokerWindow(start, end time.Time) (time.Time, time.Time) {
if end.Sub(start).Hours()/24 > maxBrokerWindowDays {
start = end.AddDate(0, 0, -maxBrokerWindowDays)
}
return start, end
}
// ClampSeriesWindow clamps a date range to the 90-day series limit.
func ClampSeriesWindow(start, end time.Time) (time.Time, time.Time) {
if end.Sub(start).Hours()/24 > maxSeriesDays {
start = end.AddDate(0, 0, -maxSeriesDays)
}
return start, end
}
func isoDate(t time.Time) string { return t.Format("2006-01-02") }
// Get performs a GET against a v2 path, enforcing auth, retry, and credit
// counting. Callers pass paths like "v2/brokers/top/" or "brokers/top/".
func (c *Client) Get(ctx context.Context, path string, q url.Values) ([]byte, error) {
if strings.TrimSpace(c.apiKey) == "" {
return nil, ErrNoAPIKey
}
if strings.Contains(path, "/v1/") || strings.HasPrefix(path, "v1/") {
return nil, ErrV1Gone
}
if e := q.Get("end"); e != "" {
if t, err := time.Parse("2006-01-02", e); err == nil && t.After(time.Now().Add(24*time.Hour)) {
return nil, fmt.Errorf("sectors: future end date %s rejected (API returns 400)", e)
}
}
rel := strings.TrimPrefix(strings.TrimPrefix(path, "/"), "v2/")
endpoint := "v2/" + strings.Trim(rel, "/") + "/"
credits := costFor(path, q)
full := c.baseURL + rel
if len(q) > 0 {
full += "?" + q.Encode()
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt*attempt) * 500 * time.Millisecond):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
lastErr = err
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
switch {
case resp.StatusCode == http.StatusOK:
c.count(endpoint, 1, credits)
return body, nil
case resp.StatusCode == http.StatusGone:
return nil, ErrV1Gone
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
lastErr = fmt.Errorf("sectors: %s -> HTTP %d (retrying)", endpoint, resp.StatusCode)
continue
default:
c.count(endpoint, 1, credits)
return nil, fmt.Errorf("sectors: %s -> HTTP %d: %s", endpoint, resp.StatusCode, truncate(string(body), 300))
}
}
c.count(endpoint, 1, credits)
return nil, fmt.Errorf("sectors: %s failed after retries: %w", endpoint, lastErr)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// EstimateCycleCredits returns the expected credit spend of one 30-min
// ingestion cycle for W watchlist tickers (docs/API-REFERENCE.md §budget).
func EstimateCycleCredits(w int) int {
return 10 + 2 + 2 + 1 + 2 + 3*w + 3 + 2 // ~= 22 + 3W
}
// MorningBriefingExtra returns (min, max) extra credits for the briefing run.
func MorningBriefingExtra() (int, int) { return 35, 40 }
+129
View File
@@ -0,0 +1,129 @@
package sectors
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// One live-shape call against a mock upstream: narrowing is enforced and
// credits counted per the API-REFERENCE cost table.
func TestNarrowingAndCredits(t *testing.T) {
var gotQ url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQ = r.URL.Query()
w.Write([]byte(`{"top_gainers":{"1d":[]},"top_losers":{"1d":[]}}`))
}))
defer srv.Close()
c := New(srv.URL+"/", "test-key")
_, _, err := c.TopChanges(context.Background(),
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 5)
if err != nil {
t.Fatal(err)
}
if gotQ.Get("classifications") != "top_gainers" {
t.Fatalf("classifications = %q, want narrowed", gotQ.Get("classifications"))
}
if gotQ.Get("periods") != "1d,7d" {
t.Fatalf("periods = %q, want narrowed", gotQ.Get("periods"))
}
if got := c.CreditsToday(); got != 2 {
t.Fatalf("credits = %d, want 2 (1 class x 2 periods)", got)
}
}
// Per-cycle budget: EstimateCycleCredits(20) must stay <= 120 cap.
func TestCycleBudget(t *testing.T) {
if got := EstimateCycleCredits(20); got > 120 {
t.Fatalf("cycle estimate = %d, exceeds 120 cap", got)
}
if got := EstimateCycleCredits(5); got != 22+3*5 {
t.Fatalf("cycle estimate = %d, want %d", got, 22+3*5)
}
}
// v1 paths are rejected before any HTTP happens.
func TestV1Rejected(t *testing.T) {
c := New("https://api.sectors.app/v2/", "k")
if _, err := c.Get(context.Background(), "v1/companies/", url.Values{}); err != ErrV1Gone {
t.Fatalf("err = %v, want ErrV1Gone", err)
}
}
// Future end dates are rejected (API returns 400).
func TestFutureEndRejected(t *testing.T) {
c := New("https://api.sectors.app/v2/", "k")
q := url.Values{"end": {"2099-01-01"}}
if _, err := c.Get(context.Background(), "daily/BBCA/", q); err == nil {
t.Fatal("want future-end rejection")
}
}
// New wrappers hit exact paths with exact params; all cost 1 credit.
func TestCompanyDepthWrappers(t *testing.T) {
var gotPath, gotQ string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQ = r.URL.Path, r.URL.RawQuery
switch {
case strings.HasPrefix(r.URL.Path, "/company/get-segments/"):
w.Write([]byte(`{"symbol":"BBCA.JK","financial_year":2025,"revenue_breakdown":[{"value":1,"source":"Loans","target":"Interest Income"}]}`))
case strings.HasPrefix(r.URL.Path, "/company/get_quarterly_financial_dates/"):
w.Write([]byte(`{"2026":[["2026-03-31","q1"]]}`))
case strings.HasPrefix(r.URL.Path, "/listing-performance/"):
w.Write([]byte(`{"symbol":"BBCA.JK","listing_date":"2000-05-31"}`))
case strings.HasPrefix(r.URL.Path, "/index-daily/"):
w.Write([]byte(`[{"index_code":"ihsg","date":"2026-09-11","price":7800.5}]`))
case strings.HasPrefix(r.URL.Path, "/company/shareholders-composition/"):
w.Write([]byte(`{"symbol":"BBCA.JK","year":2026,"data":[]}`))
default:
w.Write([]byte(`{"BBCA.JK":{"financial_year":[2025]}}`))
}
}))
defer srv.Close()
c := New(srv.URL+"/", "test-key")
if _, err := c.Segments(context.Background(), "BBCA", 2025); err != nil {
t.Fatal(err)
}
if gotQ != "financial_year=2025" {
t.Fatalf("segments query = %q", gotQ)
}
qd, err := c.QuarterlyDates(context.Background(), "BBCA")
if err != nil || len(qd) != 1 || qd[0].ReportDate != "2026-03-31" {
t.Fatalf("quarterly dates = %v, %v", qd, err)
}
if _, err := c.ListingPerformance(context.Background(), "BBCA"); err != nil {
t.Fatal(err)
}
if _, err := c.IndexDaily(context.Background(), "ihsg", "2026-09-01", "2026-09-11"); err != nil {
t.Fatal(err)
}
if _, err := c.Shareholders(context.Background(), "BBCA", 2026); err != nil {
t.Fatal(err)
}
avail, err := c.SegmentAvailability(context.Background())
if err != nil || len(avail["BBCA.JK"]) != 1 {
t.Fatalf("availability = %v, %v", avail, err)
}
_ = gotPath
}
// Event windows clamp to 90d and report real costs.
func TestEventClamps(t *testing.T) {
var gotQ url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQ = r.URL.Query()
w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
c := New(srv.URL+"/", "test-key")
_, _ = c.News(context.Background(), "BBCA", "2026-01-01", "2026-09-11", "", 20)
if gotQ.Get("start") != "2026-06-13" {
t.Fatalf("news start = %q, want clamped to 90d", gotQ.Get("start"))
}
if got := c.CreditsToday(); got < 1 {
t.Fatalf("credits = %d, want counted", got)
}
}
+719
View File
@@ -0,0 +1,719 @@
package sectors
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
)
// param helpers keep every wrapper narrow by default (credit traps in
// docs/API-REFERENCE.md): sections explicit, classifications+periods minimal,
// n_quarters bounded, free-float filters exclusive.
func dateRange(start, end string, clamp func(time.Time, time.Time) (time.Time, time.Time)) (string, string) {
if start == "" || end == "" {
e := time.Now()
s := e.AddDate(0, 0, -7)
if start == "" && end != "" {
if t, err := time.Parse("2006-01-02", end); err == nil {
e, s = t, t.AddDate(0, 0, -7)
}
}
return s.Format("2006-01-02"), e.Format("2006-01-02")
}
ts, err1 := time.Parse("2006-01-02", start)
te, err2 := time.Parse("2006-01-02", end)
if err1 != nil || err2 != nil {
return start, end
}
ts, te = clamp(ts, te)
return ts.Format("2006-01-02"), te.Format("2006-01-02")
}
func decode(data []byte, v any, endpoint string) error {
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("sectors: decode %s: %w", endpoint, err)
}
return nil
}
// --- Brokers: the moat ---
// BrokersRegistry returns the broker registry (cached daily by the scheduler).
func (c *Client) BrokersRegistry(ctx context.Context, cohort, origin string) ([]BrokerRegistryRow, error) {
q := url.Values{}
if cohort != "" && cohort != "all" {
q.Set("cohort", cohort)
}
if origin != "" && origin != "all" {
q.Set("origin", origin)
}
raw, err := c.Get(ctx, "brokers/", q)
if err != nil {
return nil, err
}
var rows []BrokerRegistryRow
if err := decode(raw, &rows, "v2/brokers/"); err != nil {
return nil, err
}
return rows, nil
}
// BrokersTop returns the daily broker ranking.
func (c *Client) BrokersTop(ctx context.Context, date, metric string, n int, origin, cohort string) ([]TopBrokerRow, error) {
q := url.Values{}
if date != "" {
q.Set("date", date)
}
if metric == "" {
metric = "net"
}
q.Set("metric", metric)
if n <= 0 || n > 90 {
n = 20
}
q.Set("n_brokers", fmt.Sprint(n))
if origin != "" {
q.Set("origin", origin)
}
if cohort != "" {
q.Set("cohort", cohort)
}
raw, err := c.Get(ctx, "brokers/top/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []TopBrokerRow `json:"results"`
}
if err := decode(raw, &resp, "v2/brokers/top/"); err != nil {
return nil, err
}
return resp.Results, nil
}
// BrokerActivity returns all (stock,day) rows for one broker (window ≤14d).
func (c *Client) BrokerActivity(ctx context.Context, brokerCode, symbol, start, end string) ([]BrokerRow, error) {
start, end = dateRange(start, end, ClampBrokerWindow)
q := url.Values{"start": {start}, "end": {end}}
if symbol != "" {
q.Set("symbol", strings.ToUpper(symbol))
}
raw, err := c.Get(ctx, "broker-activity/"+strings.ToUpper(brokerCode)+"/", q)
if err != nil {
return nil, err
}
var rows []BrokerRow
if err := decode(raw, &rows, "v2/broker-activity/{broker_code}/"); err != nil {
return nil, err
}
return rows, nil
}
// BrokerActivityTop returns top accumulations/distributions for one broker.
func (c *Client) BrokerActivityTop(ctx context.Context, brokerCode, start, end string, n int) (accum, distrib []BrokerRow, err error) {
start, end = dateRange(start, end, ClampBrokerWindow)
q := url.Values{"start": {start}, "end": {end}}
if n > 0 {
q.Set("n_brokers", fmt.Sprint(n))
}
raw, err := c.Get(ctx, "broker-activity/"+strings.ToUpper(brokerCode)+"/top/", q)
if err != nil {
return nil, nil, err
}
var resp struct {
TopAccumulations []BrokerRow `json:"top_accumulations"`
TopDistributions []BrokerRow `json:"top_distributions"`
TopAccumulation []BrokerRow `json:"top_accumulation"`
TopDistribution []BrokerRow `json:"top_distribution"`
Accumulations []BrokerRow `json:"accumulations"`
Distributions []BrokerRow `json:"distributions"`
Data []BrokerRow `json:"data"`
Results []BrokerRow `json:"results"`
}
if err := decode(raw, &resp, "v2/broker-activity/{code}/top/"); err != nil {
return nil, nil, err
}
accum = append(append(append(resp.TopAccumulations, resp.TopAccumulation...), resp.Accumulations...), resp.Data...)
distrib = append(append(resp.TopDistributions, resp.TopDistribution...), resp.Distributions...)
if len(accum) == 0 && len(resp.Results) > 0 {
accum = resp.Results
}
return accum, distrib, nil
}
// BrokerSummary returns per-broker daily rows for one ticker (window ≤14d).
func (c *Client) BrokerSummary(ctx context.Context, symbol, brokerCode, start, end string) ([]BrokerRow, error) {
start, end = dateRange(start, end, ClampBrokerWindow)
q := url.Values{"start": {start}, "end": {end}}
if brokerCode != "" {
q.Set("broker_code", strings.ToUpper(brokerCode))
}
raw, err := c.Get(ctx, "broker-summary/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
// Shape varies (rows array or {dates: {broker: rows}}); normalize both.
var rows []BrokerRow
if err := json.Unmarshal(raw, &rows); err == nil {
return rows, nil
}
var byDate map[string]map[string][]BrokerRow
if err := json.Unmarshal(raw, &byDate); err == nil {
for _, brokers := range byDate {
for _, r := range brokers {
rows = append(rows, r...)
}
}
return rows, nil
}
var wrapped struct {
Data []BrokerRow `json:"data"`
Results []BrokerRow `json:"results"`
}
if err := decode(raw, &wrapped, "v2/broker-summary/{symbol}/"); err != nil {
return nil, err
}
return append(wrapped.Data, wrapped.Results...), nil
}
// BrokerSummaryTop returns top buyers/sellers for one ticker (2 credits).
func (c *Client) BrokerSummaryTop(ctx context.Context, symbol, start, end string, n int, origin, cohort string) (*BrokerSummaryTop, error) {
start, end = dateRange(start, end, ClampBrokerWindow)
q := url.Values{"start": {start}, "end": {end}}
if n > 0 {
q.Set("n_brokers", fmt.Sprint(n))
}
if origin != "" {
q.Set("origin", origin)
}
if cohort != "" {
q.Set("cohort", cohort)
}
raw, err := c.Get(ctx, "broker-summary/"+strings.ToUpper(symbol)+"/top/", q)
if err != nil {
return nil, err
}
var resp BrokerSummaryTop
if err := decode(raw, &resp, "v2/broker-summary/{symbol}/top/"); err != nil {
return nil, err
}
return &resp, nil
}
// ForeignFlow returns the daily net foreign inflow series (window ≤90d).
func (c *Client) ForeignFlow(ctx context.Context, symbol, start, end string) (*ForeignFlow, error) {
start, end = dateRange(start, end, ClampSeriesWindow)
q := url.Values{"start": {start}, "end": {end}}
raw, err := c.Get(ctx, "foreign-flow/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var resp ForeignFlow
if err := decode(raw, &resp, "v2/foreign-flow/{symbol}/"); err != nil {
return nil, err
}
return &resp, nil
}
// --- Company core ---
// CompanyReport fetches explicit sections only (1 credit/section).
func (c *Client) CompanyReport(ctx context.Context, symbol string, sections []string) (map[string]any, error) {
if len(sections) == 0 {
sections = []string{"overview", "valuation"}
}
q := url.Values{"sections": {strings.Join(sections, ",")}}
raw, err := c.Get(ctx, "company/report/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var out map[string]any
if err := decode(raw, &out, "v2/company/report/{symbol}/"); err != nil {
return nil, err
}
return out, nil
}
// Quarterly fetches up to n financial quarters (1 credit/quarter, n≤8).
func (c *Client) Quarterly(ctx context.Context, symbol string, n int) ([]QuarterRow, error) {
if n <= 0 {
n = 4
}
if n > 8 {
n = 8
}
q := url.Values{"n_quarters": {fmt.Sprint(n)}}
raw, err := c.Get(ctx, "financials/quarterly/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var rows []QuarterRow
if err := decode(raw, &rows, "v2/financials/quarterly/{symbol}/"); err != nil {
return nil, err
}
return rows, nil
}
// CorporateActions fetches splits/rights/warrants/AGM/dividends for one ticker.
func (c *Client) CorporateActions(ctx context.Context, symbol string) (*CorporateActions, error) {
raw, err := c.Get(ctx, "company/corporate-actions/"+strings.ToUpper(symbol)+"/", url.Values{})
if err != nil {
return nil, err
}
var resp struct {
Symbol string `json:"symbol"`
CorporateActions struct {
Dividend []DividendEvent `json:"dividend"`
UpcomingDividend []DividendEvent `json:"upcoming_dividend"`
AGM []DividendEvent `json:"agm"`
StockSplit []DividendEvent `json:"stock_split"`
} `json:"corporate_actions"`
}
if err := decode(raw, &resp, "v2/company/corporate-actions/{symbol}/"); err != nil {
return nil, err
}
return &CorporateActions{
Symbol: resp.Symbol,
Dividend: resp.CorporateActions.Dividend,
UpcomingDividend: resp.CorporateActions.UpcomingDividend,
AGM: resp.CorporateActions.AGM,
StockSplit: resp.CorporateActions.StockSplit,
}, nil
}
// --- Universe / market ---
// ClosePage fetches one page of the full-universe close sweep.
func (c *Client) ClosePage(ctx context.Context, date string, limit, offset int) ([]CloseRow, int, error) {
q := url.Values{}
if date != "" {
q.Set("date", date)
}
if limit <= 0 || limit > 30 {
limit = 30
}
q.Set("limit", fmt.Sprint(limit))
q.Set("offset", fmt.Sprint(offset))
raw, err := c.Get(ctx, "close/", q)
if err != nil {
return nil, 0, err
}
var resp struct {
Results []CloseRow `json:"results"`
Pagination struct {
Total int `json:"total"`
Count int `json:"count"`
} `json:"pagination"`
}
if err := decode(raw, &resp, "v2/close/"); err != nil {
return nil, 0, err
}
return resp.Results, resp.Pagination.Total, nil
}
// QuarterlyDatesSince polls newly-reported companies incrementally.
func (c *Client) QuarterlyDatesSince(ctx context.Context, since string, limit, offset int) ([]QuarterlyDateRow, error) {
q := url.Values{}
if since != "" {
q.Set("since", since)
}
if limit <= 0 || limit > 30 {
limit = 30
}
q.Set("limit", fmt.Sprint(limit))
q.Set("offset", fmt.Sprint(offset))
raw, err := c.Get(ctx, "companies/quarterly-financial-dates/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []QuarterlyDateRow `json:"results"`
Data []QuarterlyDateRow `json:"data"`
}
if err := decode(raw, &resp, "v2/companies/quarterly-financial-dates/"); err != nil {
return nil, err
}
return append(resp.Results, resp.Data...), nil
}
// Daily returns the price+volume+MCap series for one ticker (window ≤90d).
func (c *Client) Daily(ctx context.Context, symbol, start, end string) ([]DailyBar, error) {
start, end = dateRange(start, end, ClampSeriesWindow)
q := url.Values{"start": {start}, "end": {end}}
raw, err := c.Get(ctx, "daily/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var rows []DailyBar
if err := decode(raw, &rows, "v2/daily/{symbol}/"); err != nil {
return nil, err
}
return rows, nil
}
// IdxTotal returns the IHSG total-MCap trend (macro context).
func (c *Client) IdxTotal(ctx context.Context, start, end string) ([]IdxTotalPoint, error) {
start, end = dateRange(start, end, ClampSeriesWindow)
q := url.Values{"start": {start}, "end": {end}}
raw, err := c.Get(ctx, "idx-total/", q)
if err != nil {
return nil, err
}
var rows []IdxTotalPoint
if err := decode(raw, &rows, "v2/idx-total/"); err != nil {
return nil, err
}
return rows, nil
}
// TopChanges requests minimal class×period combos only (1 credit per combo).
func (c *Client) TopChanges(ctx context.Context, classifications, periods []string, subSector string, n int) (gainers, losers map[string][]MoverRow, err error) {
if len(classifications) == 0 {
classifications = []string{"top_gainers"}
}
if len(periods) == 0 {
periods = []string{"1d"}
}
q := url.Values{
"classifications": {strings.Join(classifications, ",")},
"periods": {strings.Join(periods, ",")},
}
if subSector != "" {
q.Set("sub_sector", subSector)
}
if n > 0 {
q.Set("n_stock", fmt.Sprint(n))
}
raw, err := c.Get(ctx, "companies/top-changes/", q)
if err != nil {
return nil, nil, err
}
var resp struct {
TopGainers map[string][]MoverRow `json:"top_gainers"`
TopLosers map[string][]MoverRow `json:"top_losers"`
}
if err := decode(raw, &resp, "v2/companies/top-changes/"); err != nil {
return nil, nil, err
}
return resp.TopGainers, resp.TopLosers, nil
}
// MostTraded returns relative volume leaders.
func (c *Client) MostTraded(ctx context.Context, start, end, subSector string, n int) ([]map[string]any, error) {
start, end = dateRange(start, end, ClampSeriesWindow)
q := url.Values{"start": {start}, "end": {end}}
if subSector != "" {
q.Set("sub_sector", subSector)
}
if n > 0 {
q.Set("n_stock", fmt.Sprint(n))
}
raw, err := c.Get(ctx, "most-traded/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []map[string]any `json:"results"`
Data []map[string]any `json:"data"`
}
if err := decode(raw, &resp, "v2/most-traded/"); err != nil {
return nil, err
}
return append(resp.Results, resp.Data...), nil
}
// --- Events (incremental) ---
// News fetches articles incrementally (extension idx/mining exclusive).
// Windows clamp to 90d like other series endpoints.
func (c *Client) News(ctx context.Context, symbols, start, end, keyword string, limit int) ([]NewsArticle, error) {
if start != "" || end != "" {
start, end = dateRange(start, end, ClampSeriesWindow)
}
q := url.Values{"extension": {"idx"}}
if symbols != "" {
q.Set("symbols", symbols)
}
if start != "" {
q.Set("start", start)
}
if end != "" {
q.Set("end", end)
}
if keyword != "" {
q.Set("keyword", keyword)
}
if limit > 0 {
q.Set("limit", fmt.Sprint(limit))
}
raw, err := c.Get(ctx, "news/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []NewsArticle `json:"results"`
}
if err := decode(raw, &resp, "v2/news/"); err != nil {
return nil, err
}
return resp.Results, nil
}
// Filings fetches insider/institution transactions incrementally.
// Windows clamp to 90d like other series endpoints.
func (c *Client) Filings(ctx context.Context, symbol, txnType, holderType, start, end string) ([]Filing, error) {
if start != "" || end != "" {
start, end = dateRange(start, end, ClampSeriesWindow)
}
q := url.Values{}
if symbol != "" {
q.Set("symbol", strings.ToUpper(symbol))
}
if txnType != "" {
q.Set("transaction_type", txnType)
}
if holderType != "" {
q.Set("holder_type", holderType)
}
if start != "" {
q.Set("start", start)
}
if end != "" {
q.Set("end", end)
}
raw, err := c.Get(ctx, "filings/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []Filing `json:"results"`
Data []Filing `json:"data"`
}
if err := decode(raw, &resp, "v2/filings/"); err != nil {
return nil, err
}
return append(resp.Results, resp.Data...), nil
}
// Suspensions fetches new IDX suspension notices.
// Windows clamp to 90d like other series endpoints.
func (c *Client) Suspensions(ctx context.Context, symbol, start, end string) ([]Suspension, error) {
if start != "" || end != "" {
start, end = dateRange(start, end, ClampSeriesWindow)
}
q := url.Values{}
if symbol != "" {
q.Set("symbol", strings.ToUpper(symbol))
}
if start != "" {
q.Set("start", start)
}
if end != "" {
q.Set("end", end)
}
raw, err := c.Get(ctx, "suspensions/", q)
if err != nil {
return nil, err
}
var resp struct {
Results []Suspension `json:"results"`
Data []Suspension `json:"data"`
}
if err := decode(raw, &resp, "v2/suspensions/"); err != nil {
return nil, err
}
return append(resp.Results, resp.Data...), nil
}
// --- Screener / taxonomy ---
// Screen runs the companies/ structured screener (q overrides all).
func (c *Client) Screen(ctx context.Context, where, q string, limit, offset int) ([]ScreenerRow, error) {
qq := url.Values{}
if q != "" {
qq.Set("q", q)
} else if where != "" {
qq.Set("where", where)
}
if limit <= 0 || limit > 200 {
limit = 50
}
qq.Set("limit", fmt.Sprint(limit))
qq.Set("offset", fmt.Sprint(offset))
raw, err := c.Get(ctx, "companies/", qq)
if err != nil {
return nil, err
}
var resp struct {
Results []ScreenerRow `json:"results"`
Data []ScreenerRow `json:"data"`
}
if err := decode(raw, &resp, "v2/companies/"); err != nil {
return nil, err
}
return append(resp.Results, resp.Data...), nil
}
// FreeFloat fetches one exclusive filter per request (API rejects mixed).
func (c *Client) FreeFloat(ctx context.Context, sector, subSector, industry, subIndustry string) ([]FreeFloatRow, error) {
q := url.Values{}
set := 0
for k, v := range map[string]string{"sector": sector, "sub_sector": subSector, "industry": industry, "sub_industry": subIndustry} {
if v != "" {
q.Set(k, v)
set++
}
}
if set != 1 {
return nil, fmt.Errorf("sectors: free-float needs exactly one filter (got %d)", set)
}
raw, err := c.Get(ctx, "free-float/", q)
if err != nil {
return nil, err
}
var rows []FreeFloatRow
if err := decode(raw, &rows, "v2/free-float/"); err != nil {
return nil, err
}
return rows, nil
}
// Taxonomy fetches one slug list (subsectors/industries/subindustries/tags).
func (c *Client) Taxonomy(ctx context.Context, kind string) ([]map[string]any, error) {
raw, err := c.Get(ctx, kind+"/", url.Values{})
if err != nil {
return nil, err
}
var rows []map[string]any
if err := decode(raw, &rows, "v2/"+kind+"/"); err != nil {
return nil, err
}
return rows, nil
}
// SubsectorReport fetches explicit sections only (1 credit/section).
func (c *Client) SubsectorReport(ctx context.Context, subSector string, sections []string) (map[string]any, error) {
if len(sections) == 0 {
sections = []string{"statistics"}
}
q := url.Values{"sections": {strings.Join(sections, ",")}}
raw, err := c.Get(ctx, "subsector/report/"+subSector+"/", q)
if err != nil {
return nil, err
}
var out map[string]any
if err := decode(raw, &out, "v2/subsector/report/{sub_sector}/"); err != nil {
return nil, err
}
return out, nil
}
// --- Company depth: segments, quarterly dates, IPO, holders, index ---
// Segments fetches the revenue breakdown (Sankey-ready) for one ticker.
func (c *Client) Segments(ctx context.Context, symbol string, year int) (*Segments, error) {
q := url.Values{}
if year >= 1900 && year <= 2026 {
q.Set("financial_year", fmt.Sprint(year))
}
raw, err := c.Get(ctx, "company/get-segments/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var out Segments
if err := decode(raw, &out, "v2/company/get-segments/{symbol}/"); err != nil {
return nil, err
}
return &out, nil
}
// QuarterlyDates returns valid [report_date, quarter] pairs for one ticker
// (use to resolve report_date before Quarterly calls).
func (c *Client) QuarterlyDates(ctx context.Context, symbol string) ([]QuarterlyDate, error) {
raw, err := c.Get(ctx, "company/get_quarterly_financial_dates/"+strings.ToUpper(symbol)+"/", url.Values{})
if err != nil {
return nil, err
}
// Shape: {"YYYY-MM-DD": [["report_date","quarter"], ...], ...} flattened.
var grouped map[string][][]string
if err := decode(raw, &grouped, "v2/company/get_quarterly_financial_dates/{symbol}/"); err != nil {
return nil, err
}
var out []QuarterlyDate
for _, pairs := range grouped {
for _, p := range pairs {
if len(p) >= 2 {
out = append(out, QuarterlyDate{ReportDate: p[0], Quarter: p[1]})
}
}
}
return out, nil
}
// ListingPerformance fetches IPO context (post-May-2005 listings only).
func (c *Client) ListingPerformance(ctx context.Context, symbol string) (*ListingPerformance, error) {
raw, err := c.Get(ctx, "listing-performance/"+strings.ToUpper(symbol)+"/", url.Values{})
if err != nil {
return nil, err
}
var out ListingPerformance
if err := decode(raw, &out, "v2/listing-performance/{symbol}/"); err != nil {
return nil, err
}
return &out, nil
}
// IndexDaily returns the benchmark series for beta/correlation (window ≤90d).
func (c *Client) IndexDaily(ctx context.Context, indexCode, start, end string) ([]IndexBar, error) {
start, end = dateRange(start, end, ClampSeriesWindow)
q := url.Values{"start": {start}, "end": {end}}
raw, err := c.Get(ctx, "index-daily/"+strings.ToLower(indexCode)+"/", q)
if err != nil {
return nil, err
}
var rows []IndexBar
if err := decode(raw, &rows, "v2/index-daily/{index_code}/"); err != nil {
return nil, err
}
return rows, nil
}
// Shareholders fetches the local-vs-foreign holder mix (data from 2021).
func (c *Client) Shareholders(ctx context.Context, symbol string, year int) (*ShareholdersComposition, error) {
q := url.Values{}
if year >= 2021 {
q.Set("year", fmt.Sprint(year))
}
raw, err := c.Get(ctx, "company/shareholders-composition/"+strings.ToUpper(symbol)+"/", q)
if err != nil {
return nil, err
}
var out ShareholdersComposition
if err := decode(raw, &out, "v2/company/shareholders-composition/{symbol}/"); err != nil {
return nil, err
}
return &out, nil
}
// SegmentAvailability checks which symbols have segment data (cache weekly).
func (c *Client) SegmentAvailability(ctx context.Context) (map[string][]int, error) {
raw, err := c.Get(ctx, "companies/list_companies_with_segments/", url.Values{})
if err != nil {
return nil, err
}
var out map[string]struct {
Years []int `json:"financial_year"`
}
if err := decode(raw, &out, "v2/companies/list_companies_with_segments/"); err != nil {
return nil, err
}
res := make(map[string][]int, len(out))
for k, v := range out {
res[k] = v.Years
}
return res, nil
}
+239
View File
@@ -0,0 +1,239 @@
// Package sectors response types mirror docs.sectors.app/schema.json shapes.
package sectors
// BrokerTopRow is one top buyer/seller entry (net/buy/sell in IDR).
type BrokerTopRow struct {
Rank int `json:"rank"`
BrokerCode string `json:"broker_code"`
NetIDR int64 `json:"net_idr"`
BuyIDR int64 `json:"buy_idr"`
SellIDR int64 `json:"sell_idr"`
}
// BrokerSummaryTop is GET broker-summary/{symbol}/top/ response.
type BrokerSummaryTop struct {
Symbol string `json:"symbol"`
Start string `json:"start"`
End string `json:"end"`
Origin string `json:"origin"`
Cohort string `json:"cohort"`
TopBuyers []BrokerTopRow `json:"top_buyers"`
TopSellers []BrokerTopRow `json:"top_sellers"`
}
// BrokerRow is one per-broker daily row (blot/bval buy, slot/sval sell, nlot/nval net).
type BrokerRow struct {
BrokerCode string `json:"broker_code"`
Symbol string `json:"symbol,omitempty"`
BFreq int `json:"bfreq"`
BLot int64 `json:"blot"`
BVal int64 `json:"bval"`
BAvg float64 `json:"bavg_per_share"`
SFreq int `json:"sfreq"`
SLot int64 `json:"slot"`
SVal int64 `json:"sval"`
SAvg float64 `json:"savg_per_share"`
NLot int64 `json:"nlot"`
NVal int64 `json:"nval"`
NAvg float64 `json:"navg_per_share"`
}
// BrokerRegistryRow classifies one broker code.
type BrokerRegistryRow struct {
Code string `json:"code"`
Name string `json:"name"`
IsForeign bool `json:"is_foreign"`
Cohort *string `json:"cohort"`
LicenseType *string `json:"license_type"`
}
// TopBrokerRow is one brokers/top/ ranking row.
type TopBrokerRow struct {
Rank int `json:"rank"`
BrokerCode string `json:"broker_code"`
Gross int64 `json:"gross"`
Net int64 `json:"net"`
}
// ForeignPoint is one day of net foreign inflow (IDR, signed).
type ForeignPoint struct {
Date string `json:"date"`
NetForeignInflow int64 `json:"net_foreign_inflow"`
}
// ForeignFlow is GET foreign-flow/{symbol}/ response.
type ForeignFlow struct {
Symbol string `json:"symbol"`
Start string `json:"start"`
End string `json:"end"`
Data []ForeignPoint `json:"data"`
}
// DailyBar is one daily OHLCV row.
type DailyBar struct {
Symbol string `json:"symbol"`
Date string `json:"date"`
Close int64 `json:"close"`
Open int64 `json:"open"`
High int64 `json:"high"`
Low int64 `json:"low"`
Volume int64 `json:"volume"`
MarketCap int64 `json:"market_cap"`
}
// MoverRow is one top-changes entry; PriceChange is decimal (0.05 = +5%).
type MoverRow struct {
Name string `json:"name"`
Symbol string `json:"symbol"`
PriceChange float64 `json:"price_change"`
LastClosePrice int64 `json:"last_close_price"`
LatestCloseDate string `json:"latest_close_date"`
}
// CloseRow is one close/ universe row.
type CloseRow struct {
Symbol string `json:"symbol"`
Date string `json:"date"`
Close int64 `json:"close"`
}
// NewsArticle is one normalized news item.
type NewsArticle struct {
Title string `json:"title"`
Body string `json:"body"`
Source string `json:"source"`
Timestamp string `json:"timestamp"`
Sector string `json:"sector"`
SubSector []string `json:"sub_sector"`
Tags []string `json:"tags"`
Symbols []string `json:"symbols"`
}
// Filing is one insider/institution transaction.
type Filing struct {
Title string `json:"title"`
Symbol string `json:"symbol"`
Timestamp string `json:"timestamp"`
TransactionType string `json:"transaction_type"`
HolderType string `json:"holder_type"`
HolderName string `json:"holder_name"`
AmountTransaction int64 `json:"amount_transaction"`
Price float64 `json:"price"`
TransactionValue float64 `json:"transaction_value"`
Source string `json:"source"`
}
// Suspension is one IDX suspension notice.
type Suspension struct {
Symbol string `json:"symbol"`
SuspensionDate string `json:"suspension_date"`
Reason string `json:"reason"`
PDFURL string `json:"pdf_url"`
}
// ScreenerRow is one companies/ result row.
type ScreenerRow struct {
Symbol string `json:"symbol"`
CompanyName string `json:"company_name"`
QueryValues map[string]any `json:"query_values"`
}
// QuarterRow is one financials/quarterly row.
type QuarterRow struct {
Symbol string `json:"symbol"`
Date string `json:"date"`
Revenue *float64 `json:"revenue"`
Earnings *float64 `json:"earnings"`
Assets *float64 `json:"total_assets"`
Equity *float64 `json:"total_equity"`
OpCash *float64 `json:"operating_cash_flow"`
Debt *float64 `json:"total_debt"`
}
// DividendEvent is one dividend corporate-action event (raw map: schema untyped).
type DividendEvent map[string]any
// CorporateActions groups events by type.
type CorporateActions struct {
Symbol string `json:"symbol"`
Dividend []DividendEvent `json:"dividend"`
UpcomingDividend []DividendEvent `json:"upcoming_dividend"`
AGM []DividendEvent `json:"agm"`
StockSplit []DividendEvent `json:"stock_split"`
}
// QuarterlyDateRow is one companies/quarterly-financial-dates entry.
type QuarterlyDateRow struct {
Symbol string `json:"symbol"`
Date string `json:"date"`
Year int `json:"year"`
}
// FreeFloatRow is one free-float/ entry (decimal, 0.45 = 45%).
type FreeFloatRow struct {
Symbol string `json:"symbol"`
CompanyName string `json:"company_name"`
FreeFloat float64 `json:"free_float"`
}
// IdxTotalPoint is one idx-total/ row (raw map: shape varies by date range).
type IdxTotalPoint map[string]any
// SegmentRow is one revenue-breakdown entry (Sankey-ready source/target).
type SegmentRow struct {
Value float64 `json:"value"`
Source string `json:"source"`
Target string `json:"target"`
}
// Segments is GET company/get-segments/{symbol}/ response.
type Segments struct {
Symbol string `json:"symbol"`
FinancialYear int `json:"financial_year"`
RevenueBreakdown []SegmentRow `json:"revenue_breakdown"`
}
// QuarterlyDate is one [report_date, quarter_label] pair from
// get_quarterly_financial_dates/{symbol}/.
type QuarterlyDate struct {
ReportDate string `json:"report_date"`
Quarter string `json:"quarter"`
}
// ListingPerformance is GET listing-performance/{symbol}/ (IPO context).
type ListingPerformance struct {
Symbol string `json:"symbol"`
CompanyName string `json:"company_name"`
ListingDate string `json:"listing_date"`
Chg7d *float64 `json:"chg_7d"`
Chg30d *float64 `json:"chg_30d"`
Chg90d *float64 `json:"chg_90d"`
Chg365d *float64 `json:"chg_365d"`
OfferingPrice *int64 `json:"offering_price"`
}
// IndexBar is one index-daily/{index_code}/ row.
type IndexBar struct {
IndexCode string `json:"index_code"`
Date string `json:"date"`
Price float64 `json:"price"`
}
// ShareholderRow is one shareholders-composition/{symbol}/ month row
// (local _l vs foreign _f holder mix across 9 categories).
type ShareholderRow struct {
Date string `json:"date"`
TotalL *int64 `json:"total_l"`
TotalF *int64 `json:"total_f"`
IndividualL *int64 `json:"individual_l"`
IndividualF *int64 `json:"individual_f"`
CorporateL *int64 `json:"corporate_l"`
CorporateF *int64 `json:"corporate_f"`
}
// ShareholdersComposition is GET company/shareholders-composition/{symbol}/.
type ShareholdersComposition struct {
Symbol string `json:"symbol"`
Year int `json:"year"`
Data []ShareholderRow `json:"data"`
}
+89
View File
@@ -0,0 +1,89 @@
package store
import (
"sort"
)
// ResolveDue resolves predictions older than 30d against actual return:
// first stored close on/after predict_date vs latest stored close.
// BUY hits above +5%, AVOID hits below -5%, HOLD hits within ±10%.
// Returns the number of newly resolved rows.
func (db *DB) ResolveDue() (int, error) {
due, err := db.DueForResolution()
if err != nil {
return 0, err
}
resolved := 0
for _, p := range due {
id, _ := p["id"].(int64)
ticker, _ := p["ticker"].(string)
pred, _ := p["prediction"].(string)
since, _ := p["predict_date"].(string)
series, err := db.dailyCloses(ticker)
if err != nil || len(series) < 2 {
continue
}
base, ok := firstOnOrAfter(series, since)
if !ok {
continue
}
last := series[len(series)-1]
if base.close <= 0 {
continue
}
ret := (last.close - base.close) / base.close
hit := false
switch pred {
case "BUY":
hit = ret > 0.05
case "AVOID":
hit = ret < -0.05
default: // HOLD
hit = ret >= -0.10 && ret <= 0.10
}
if err := db.ResolvePrediction(id, hit, ret); err == nil {
resolved++
}
}
return resolved, nil
}
type closePoint struct {
date string
close float64
}
// dailyCloses parses every stored daily payload into a date-sorted series.
func (db *DB) dailyCloses(ticker string) ([]closePoint, error) {
rows, err := db.Query(`SELECT payload_json FROM snapshots WHERE ticker=? AND source='daily'`, ticker)
if err != nil {
return nil, err
}
defer rows.Close()
var out []closePoint
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
for _, b := range parseDailyBars(raw, "") {
if b.Date != "" && b.Close > 0 {
out = append(out, closePoint{b.Date, b.Close})
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].date < out[j].date })
return out, nil
}
func firstOnOrAfter(series []closePoint, date string) (closePoint, bool) {
for _, p := range series {
if p.date >= date {
return p, true
}
}
return closePoint{}, false
}
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"testing"
"time"
)
// ResolveDue: BUY older than 30d resolves against stored closes.
func TestResolveDue(t *testing.T) {
db, err := Open(t.TempDir() + "/acc.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
old := time.Now().AddDate(0, 0, -40).Format("2006-01-02")
if _, err := db.Exec(`INSERT INTO agent_accuracy(agent,ticker,prediction,predict_date)
VALUES('synthesizer','BBCA','BUY',?)`, old); err != nil {
t.Fatal(err)
}
// Rising closes: first bar 9000 -> last 9950 (+10.5%) => BUY hits.
payload := `[{"date":"` + old + `","close":9000,"volume":1},{"date":"` +
time.Now().Format("2006-01-02") + `","close":9950,"volume":1}]`
if err := db.SaveSnapshot("BBCA", time.Now().Format("2006-01-02"), "daily", payload); err != nil {
t.Fatal(err)
}
n, err := db.ResolveDue()
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("resolved = %d, want 1", n)
}
stats, _ := db.AccuracyStats()
if len(stats) != 1 || stats[0]["hits"] != 1 {
t.Fatalf("stats = %v, want 1 hit", stats)
}
// Second run: nothing due.
n, _ = db.ResolveDue()
if n != 0 {
t.Fatalf("resolved = %d, want 0", n)
}
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Cache wraps Redis with an in-memory TTL fallback so the demo runs without
// REDIS_URL. Reference data (registry/taxonomy/tags) uses 24h TTL.
type Cache struct {
rdb *redis.Client
mu sync.Mutex
mem map[string]memEntry
}
type memEntry struct {
val string
exp time.Time
}
// NewCache builds a cache; empty redisURL selects the in-memory fallback.
func NewCache(redisURL string) *Cache {
c := &Cache{mem: map[string]memEntry{}}
if redisURL != "" {
opt, err := redis.ParseURL(redisURL)
if err == nil {
c.rdb = redis.NewClient(opt)
}
}
return c
}
// HasRedis reports whether a live Redis backs this cache.
func (c *Cache) HasRedis() bool { return c.rdb != nil }
// Get returns the cached value or "" on miss/expiry.
func (c *Cache) Get(ctx context.Context, key string) string {
if c.rdb != nil {
if v, err := c.rdb.Get(ctx, key).Result(); err == nil {
return v
}
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.mem[key]
if !ok || time.Now().After(e.exp) {
return ""
}
return e.val
}
// Set stores a value with TTL.
func (c *Cache) Set(ctx context.Context, key, val string, ttl time.Duration) {
if c.rdb != nil {
_ = c.rdb.Set(ctx, key, val, ttl).Err()
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.mem[key] = memEntry{val: val, exp: time.Now().Add(ttl)}
}
// GetJSON unmarshals a cached JSON value; ok=false on miss.
func (c *Cache) GetJSON(ctx context.Context, key string, v any) bool {
raw := c.Get(ctx, key)
if raw == "" {
return false
}
return json.Unmarshal([]byte(raw), v) == nil
}
// SetJSON marshals and stores a value with TTL.
func (c *Cache) SetJSON(ctx context.Context, key string, v any, ttl time.Duration) {
b, err := json.Marshal(v)
if err != nil {
return
}
c.Set(ctx, key, string(b), ttl)
}
+78
View File
@@ -0,0 +1,78 @@
// Package store persists snapshots, derived tables, routines, alerts, reports,
// the accuracy ledger, and the credit ledger on SQLite (modernc.org/sqlite,
// pure Go — no CGO so demo machines without gcc still build).
package store
import (
"database/sql"
"embed"
"fmt"
"sort"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// DB wraps database/sql with FlowSight helpers.
type DB struct {
*sql.DB
Path string
}
// Open connects to path (created if missing) and applies numbered migrations.
func Open(path string) (*DB, error) {
sqldb, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("store: open %s: %w", path, err)
}
sqldb.SetMaxOpenConns(1)
db := &DB{DB: sqldb, Path: path}
if err := db.migrate(); err != nil {
sqldb.Close()
return nil, err
}
return db, nil
}
func (db *DB) migrate() error {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() {
names = append(names, "migrations/"+e.Name())
}
}
sort.Strings(names)
for _, n := range names {
b, err := migrationsFS.ReadFile(n)
if err != nil {
return err
}
if _, err := db.Exec(string(b)); err != nil {
return fmt.Errorf("store: migration %s: %w", n, err)
}
}
return nil
}
// Meta helpers persist incremental cursors (quarterly_since, news_since,
// filings_since) and dedup markers so restarts resume incrementally.
// GetMeta returns a meta value or "".
func (db *DB) GetMeta(key string) string {
var v string
_ = db.QueryRow(`SELECT value FROM meta WHERE key=?`, key).Scan(&v)
return v
}
// SetMeta upserts a meta value.
func (db *DB) SetMeta(key, value string) error {
_, err := db.Exec(`INSERT INTO meta(key,value) VALUES(?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}
@@ -0,0 +1,79 @@
-- FlowSight schema v1. SQLite for hackathon; kept Postgres-compatible
-- (integer PKs, TEXT JSON payloads, no SQLite-only DDL).
CREATE TABLE IF NOT EXISTS snapshots(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, date TEXT NOT NULL, source TEXT NOT NULL,
payload_json TEXT NOT NULL, fetched_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_ticker_date_source ON snapshots(ticker, date, source);
CREATE TABLE IF NOT EXISTS broker_activity(
broker_code TEXT NOT NULL, ticker TEXT NOT NULL, date TEXT NOT NULL,
buy REAL NOT NULL DEFAULT 0, sell REAL NOT NULL DEFAULT 0, net REAL NOT NULL DEFAULT 0,
lots REAL NOT NULL DEFAULT 0, freq INTEGER NOT NULL DEFAULT 0, avg_price REAL NOT NULL DEFAULT 0,
PRIMARY KEY(broker_code, ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_broker_activity_ticker_date ON broker_activity(ticker, date);
CREATE INDEX IF NOT EXISTS idx_broker_activity_broker_date ON broker_activity(broker_code, date);
CREATE TABLE IF NOT EXISTS foreign_flow(
ticker TEXT NOT NULL, date TEXT NOT NULL, net_inflow REAL NOT NULL DEFAULT 0,
PRIMARY KEY(ticker, date)
);
CREATE TABLE IF NOT EXISTS news_items(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, date TEXT NOT NULL, source TEXT NOT NULL DEFAULT '',
sentiment TEXT NOT NULL DEFAULT 'neutral', confidence REAL NOT NULL DEFAULT 0,
url TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_news_ticker_date ON news_items(ticker, date);
CREATE TABLE IF NOT EXISTS filings(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, date TEXT NOT NULL, holder_type TEXT NOT NULL DEFAULT '',
txn_type TEXT NOT NULL DEFAULT '', volume REAL NOT NULL DEFAULT 0, price REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_filings_ticker_date ON filings(ticker, date);
CREATE TABLE IF NOT EXISTS routines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_key TEXT NOT NULL, type TEXT NOT NULL, schedule_cron TEXT NOT NULL DEFAULT '',
channels_json TEXT NOT NULL DEFAULT '[]', enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS routine_runs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
routine_id INTEGER NOT NULL, started_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ok',
payload_json TEXT NOT NULL DEFAULT '{}', credits_used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id, started_at);
CREATE TABLE IF NOT EXISTS alerts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_key TEXT NOT NULL, name TEXT NOT NULL, rule_json TEXT NOT NULL,
channels_json TEXT NOT NULL DEFAULT '[]', last_fired TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS alert_events(
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER NOT NULL, ticker TEXT NOT NULL, date TEXT NOT NULL,
message TEXT NOT NULL, context_json TEXT NOT NULL DEFAULT '{}', citations_json TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_alert_events_ticker_date ON alert_events(ticker, date);
CREATE TABLE IF NOT EXISTS watchlists(
user_key TEXT NOT NULL, ticker TEXT NOT NULL, added_at TEXT NOT NULL,
PRIMARY KEY(user_key, ticker)
);
CREATE TABLE IF NOT EXISTS reports(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS agent_accuracy(
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL, ticker TEXT NOT NULL, prediction TEXT NOT NULL,
predict_date TEXT NOT NULL, resolved INTEGER NOT NULL DEFAULT 0,
hit INTEGER NOT NULL DEFAULT 0, actual_return REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_accuracy_agent ON agent_accuracy(agent, predict_date);
CREATE TABLE IF NOT EXISTS briefings(
date TEXT PRIMARY KEY, payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS credit_ledger(
date TEXT NOT NULL, endpoint TEXT NOT NULL, calls INTEGER NOT NULL DEFAULT 0,
credits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(date, endpoint)
);
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '');
@@ -0,0 +1,8 @@
-- FlowSight schema v2: holder attribution + user scoping for history reads.
ALTER TABLE filings ADD COLUMN holder_name TEXT NOT NULL DEFAULT '';
ALTER TABLE routine_runs ADD COLUMN user_key TEXT NOT NULL DEFAULT '';
ALTER TABLE alert_events ADD COLUMN user_key TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_routine_runs_user ON routine_runs(user_key, routine_id);
CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_key, ticker);
UPDATE routine_runs SET user_key=(SELECT user_key FROM routines WHERE routines.id=routine_runs.routine_id) WHERE user_key='';
UPDATE alert_events SET user_key=(SELECT user_key FROM alerts WHERE alerts.id=alert_events.alert_id) WHERE user_key='';
@@ -0,0 +1,15 @@
-- FlowSight schema v3: per-user notification destinations (CRUD, dynamic per login).
-- kind: telegram (bot_token+chat_id) or discord (webhook_url). Secrets live
-- server-side only; API responses never include them.
CREATE TABLE IF NOT EXISTS notification_destinations(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_key TEXT NOT NULL,
kind TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
webhook_url TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_destinations_user ON notification_destinations(user_key, enabled);
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
package store
import "testing"
// Retention prune keeps newest per (ticker, source) and deletes old rows.
func TestPruneSnapshots(t *testing.T) {
db, err := Open(t.TempDir() + "/prune.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
_ = db.SaveSnapshot("OLD", "2020-01-01", "daily", `[{"close":1}]`)
_ = db.SaveSnapshot("OLD", "2026-09-11", "daily", `[{"close":2}]`)
_ = db.SaveSnapshot("NEW", "2026-09-11", "daily", `[{"close":3}]`)
n, err := db.PruneSnapshots("2026-01-01")
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("pruned = %d, want 1", n)
}
if _, _, err := db.LatestSnapshot("OLD", "daily"); err != nil {
t.Fatal("newest OLD row must survive")
}
}
// DeleteRoutine + FilingsSince + ListReports parity.
func TestStoreParity(t *testing.T) {
db, err := Open(t.TempDir() + "/parity.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
id, _ := db.CreateRoutine("demo", "weekend-review", "0 9 * * 6", nil)
ok, err := db.DeleteRoutine(id, "demo")
if err != nil || !ok {
t.Fatalf("delete = %v, %v", ok, err)
}
if ok2, _ := db.DeleteRoutine(999999, "demo"); ok2 {
t.Fatal("delete of missing id must report false")
}
rows, _ := db.ListRoutines("demo")
for _, r := range rows {
if r.ID == id {
t.Fatal("routine not deleted")
}
}
_ = db.InsertFiling("BBCA", "2026-09-10", "insider", "buy", 500000, 9850)
fils, err := db.FilingsSince("BBCA", "2026-09-01", 10)
if err != nil || len(fils) != 1 {
t.Fatalf("filings = %v, %v", fils, err)
}
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
reps, err := db.ListReports("BBCA", 10)
if err != nil || len(reps) != 1 {
t.Fatalf("reports = %v, %v", reps, err)
}
}
// Destination CRUD: masks nothing at store layer, scopes by owner, kind immutable via API.
func TestDestinationCRUD(t *testing.T) {
db, err := Open(t.TempDir() + "/dest.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
id, err := db.CreateDestination(Destination{UserKey: "u1", Kind: DestTelegram, Label: "bot", BotToken: "tok", ChatID: "123", Enabled: true})
if err != nil || id == 0 {
t.Fatalf("create = %d, %v", id, err)
}
rows, _ := db.ListDestinations("u1")
if len(rows) != 1 || rows[0].BotToken != "tok" {
t.Fatalf("list = %+v", rows)
}
if other, _ := db.ListDestinations("u2"); len(other) != 0 {
t.Fatal("cross-user leak")
}
en, _ := db.ListEnabledDestinations("u1")
if len(en) != 1 {
t.Fatal("enabled list must include it")
}
off := false
ok, err := db.UpdateDestination(id, "u1", nil, &off, nil, nil, nil)
if err != nil || !ok {
t.Fatalf("disable = %v, %v", ok, err)
}
if en, _ := db.ListEnabledDestinations("u1"); len(en) != 0 {
t.Fatal("disabled row must drop from enabled list")
}
// Omitted secrets keep stored values.
ok, err = db.UpdateDestination(id, "u1", nil, nil, nil, nil, nil)
if err != nil || !ok {
t.Fatalf("noop patch = %v, %v", ok, err)
}
got, _ := db.GetDestination(id, "u1")
if got == nil || got.BotToken != "tok" {
t.Fatalf("secrets must persist on empty patch: %+v", got)
}
if ok, _ := db.UpdateDestination(id, "u2", nil, nil, nil, nil, nil); ok {
t.Fatal("cross-user patch must report false")
}
ok, err = db.DeleteDestination(id, "u1")
if err != nil || !ok {
t.Fatalf("delete = %v, %v", ok, err)
}
if ok, _ := db.DeleteDestination(id, "u1"); ok {
t.Fatal("double delete must report false")
}
}

Some files were not shown because too many files have changed in this diff Show More