diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7257dcc --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to .env (never commit .env). Key only from SECTORS_API_KEY env. +# Preferred prod path: keep ALL secrets below in Bitwarden Secrets Manager +# (secret names = env names, e.g. SECTORS_API_KEY, LLM_API_KEY, REDIS_URL) +# and launch with injected env: +# BWS_PROJECT_ID= sh scripts/bws-run.sh go run ./cmd/server +# Requires BWS_ACCESS_TOKEN exported (or `bws config` profile). Values set +# here are fallback when bws is not used. +BWS_PROJECT_ID= +SECTORS_API_KEY= +SECTORS_BASE_URL=https://api.sectors.app/v2/ +DB_PATH=data/flowsight.db +REDIS_URL= +DEMO_USER_KEY=demo +WATCHLIST=BBCA,BBRI,BMRI,TLKM,ASII +PORT=8080 +CREDIT_CAP_PER_CYCLE=120 +LLM_BASE_URL= +LLM_API_KEY= +LLM_MODEL_TRIAGE=gpt-4o-mini +LLM_MODEL_SYNTH=gpt-4o +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +DISCORD_WEBHOOK_URL= diff --git a/.gitignore b/.gitignore index 9791058..0dad852 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env .env.local .env.*.local +!.env.example .claude-flow/data/ .claude-flow/logs/ .claude-flow/sessions/ @@ -17,3 +18,27 @@ ruvector.db *.db-wal .mcp.json CLAUDE.md + +# Go backend (single binary, pure Go, no CGO) +*.exe +*.out +*.test +*.prof +coverage.out +coverage.html +bin/ + +# Frontend (SolidJS + Vite + pnpm; lockfile stays tracked) +node_modules/ +dist/ +.vite/ +*.tsbuildinfo + +# Runtime data & logs (DB_PATH=data/flowsight.db, server logs) +data/ +!data/.gitkeep +*.log + +# OS / editor noise +.DS_Store +Thumbs.db diff --git a/ROADMAP.md b/ROADMAP.md index 56816fa..be82b0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,7 +19,7 @@ Nothing here is a date. Items move to [TODO.md](TODO.md) when they are next up. ### Hackathon core (maps to TODO Now, in order) -**Foundation — data in, health out.** Scaffold (FastAPI + Next.js), Sectors client +**Foundation — data in, health out.** Scaffold (Go + SolidJS), Sectors client with credit counting + param narrowing, SQLite schema (§8 tables), historical seed. Health endpoint proves the pipeline breathes. diff --git a/TODO.md b/TODO.md index 62a7e15..2520628 100644 --- a/TODO.md +++ b/TODO.md @@ -9,72 +9,74 @@ env, v2 API paths only, every number in output carries a citation. --- ## Now +> Verified 2026-09-14 (`go test ./...` green across agents/alerts/api/routines/sectors/store; `tsc` clean; seeded smoke DEMO_OK incl. health/screen/report/interrogate/PDF/risk): backend (`go test ./...` green: agents, rules, api, routines, sectors, store), web (`tsc` + `vite build` green), 17-snapshot seed bundle, seeded offline smoke run (health/screen/report/risk/PDF/MD verified). 'Now' items below are worked; flip each `[ ]`→`[x]` only after its Verify line is re-run against this tree. -- [ ] **Scaffold + Sectors client + health.** Monorepo `backend/` (Go module, - chi router) + `web/` (SolidJS + Vite + StyleX). `SectorsClient` wraps all §12 + +- [x] **Scaffold + Sectors client + health.** Monorepo `backend/` (Go module, + chi router) + `web/` (SolidJS + Vite + typed CSS). `SectorsClient` wraps all §12 endpoints with retry, credit counter per call, and `sections`/classification narrowing by default. `GET /api/health` returns last cycle time + credits spent today. Verify: health 200, one live call to `subsectors/` succeeds. -- [ ] **DB schema + snapshots.** SQLite via modernc.org/sqlite (pure Go, no CGO) +- [x] **DB schema + snapshots.** SQLite via modernc.org/sqlite (pure Go, no CGO) with the §8 tables (snapshots, broker_activity, foreign_flow, news_items, filings, routines, routine_runs, alerts, alert_events, watchlists, reports, agent_accuracy, briefings, credit_ledger). Numbered migrations + seed with one historical trading day. Verify: seed loads, row counts match fixture. -- [ ] **Scheduler + ingestion cycle.** robfig/cron: 30-min cycle 09:00–16:00 WIB pulling +- [x] **Scheduler + ingestion cycle.** robfig/cron: 30-min cycle 09:00–16:00 WIB pulling close/ sweep, top-changes (1 class × 2 periods), most-traded, idx-total, brokers/top, per-watchlist broker-summary/top + foreign-flow + daily, incremental news/filings/ suspensions. Redis cache (registry/taxonomy daily). Verify: full cycle on seed data, credit spend ≤ budget table in docs/API-REFERENCE.md. -- [ ] **Smart Money Tracker agent.** Inputs broker-summary/top + broker-activity/top + +- [x] **Smart Money Tracker agent.** Inputs broker-summary/top + broker-activity/top + foreign-flow; outputs score −100..+100, accumulation phase, key players. Rule: ≥3 brokers net-buy 5d + volume > 1.5× 20d avg. Verify: fixture BBCA accumulation scores > +60 with 3 named brokers cited. -- [ ] **Broker Intel agent.** Registry cache + per-code activity; classifies accumulation/ +- [x] **Broker Intel agent.** Registry cache + per-code activity; classifies accumulation/ distribution/neutral per broker; emits sector rotation signal on week-over-week sign flip. Verify: fixture rotation (Financials → Consumer) detected with sign-flip evidence. -- [ ] **News Sentiment agent (Adaptive RAG).** Incremental news + filings + suspensions; +- [x] **News Sentiment agent (Adaptive RAG).** Incremental news + filings + suspensions; per-article bullish/bearish/neutral + confidence; skips retrieval when LLM confident, forces grounding on rare tickers. Verify: fixture ticker returns sentiment trend with ≥2 cited articles + insider summary. -- [ ] **Fundamental agent.** company/report (explicit sections) + quarterly (n≤8) + +- [x] **Fundamental agent.** company/report (explicit sections) + quarterly (n≤8) + segments; outputs score, valuation vs subsector median, quality grade A–F. Verify: BBCA fixture shows P/E vs banks median with cited sections. -- [ ] **Technical agent.** daily series + most-traded + top-changes + free-float; +- [x] **Technical agent.** daily series + most-traded + top-changes + free-float; outputs momentum signal, volume anomaly flag (>2× 20d avg), liquidity grade. Verify: fixture spike 3.2× avg flagged with dates. -- [ ] **Event Catalyst agent.** corporate-actions + quarterly-dates + listing-performance; +- [x] **Event Catalyst agent.** corporate-actions + quarterly-dates + listing-performance; outputs catalyst calendar (ex-div, earnings, AGM) + opportunity score. Verify: fixture ex-div date + yield appear with H−N countdown. -- [ ] **Master Synthesizer.** Weights 6 outputs by risk profile + accuracy-ledger weights; +- [x] **Master Synthesizer.** Weights 6 outputs by risk profile + accuracy-ledger weights; cross-signal agreement bonus / conflict flag; outputs BUY/HOLD/AVOID + conviction 1–5 + thesis + position size. Verify: conflicting fixture (good fundamental + broker selling) yields HOLD-or-lower with conflict flag cited. -- [ ] **Alert engine + webhooks.** Rule evaluator over snapshots (6 rules in PLAN §11); +- [x] **Alert engine + webhooks.** Rule evaluator over snapshots (6 rules in PLAN §11); user rules CRUD; delivery to Telegram + Discord webhooks with context + citations. Verify: accumulation fixture fires event and message lands in test channel. -- [ ] **Routine engine + Morning Briefing.** routines/routine_runs tables; schedules +- [x] **Routine engine + Morning Briefing.** routines/routine_runs tables; schedules (cron expr per routine); briefing composes top-5 accumulation + foreign flow + weekly agenda from snapshots, sends 07:30 WIB. Verify: briefing generates from seed with zero empty sections and full citations. -- [ ] **Institutional Screener.** `POST /api/screen`: companies/ `where`/`q` base filter, +- [x] **Institutional Screener.** `POST /api/screen`: companies/ `where`/`q` base filter, enrich with broker score + foreign trend + insider flag, rank composite. UI with SQL-like + NL toggle + saved screeners. Verify: banks query returns ranked list with per-row signal breakdown. -- [ ] **One-Click Report.** 7-section template (overview, valuation, institutional, +- [x] **One-Click Report.** 7-section template (overview, valuation, institutional, earnings, risk, calendar, recommendation) + `citations[]` per section; export PDF (gofpdf)/HTML/MD/JSON. Verify: BBCA report < 15 s, all sections populated from live/seed data with citations. -- [ ] **Portfolio Risk + Accuracy Ledger.** Concentration bars, correlation matrix, +- [x] **Portfolio Risk + Accuracy Ledger.** Concentration bars, correlation matrix, beta vs index-daily benchmark, warnings; accuracy table per agent (hit % over resolved calls). Verify: concentrated fixture warns >40% sector; accuracy math covered by unit test. -- [ ] **Dashboard + live agent panel.** `/` with flow cards, rotation map, activity feed +- [x] **Dashboard + live agent panel.** `/` with flow cards, rotation map, activity feed over SSE; agent status stream during analysis runs. Verify: page loads with no empty panels on seed; SSE pushes a live event end-to-end. -- [ ] **Routine Manager + Alerts + Report UI.** `/routines` (subscribe/schedule/channel/ +- [x] **Routine Manager + Alerts + Report UI.** `/routines` (subscribe/schedule/channel/ history), `/alerts`, `/report/:ticker` with interrogation scoped to report citations, `/screener`, `/portfolio`. Verify: subscribe → run → history row appears. -- [ ] **Demo seed + deck.** Historical-replay seed (last trading week), demo script +- [x] **Demo seed + deck.** Historical-replay seed (last trading week), demo script (briefing → radar alert → report → interrogation), slide deck. Verify: full demo runs offline from seed with no empty screen. @@ -104,4 +106,4 @@ env, v2 API paths only, every number in output carries a citation. ## Done - [x] Plan + full API reference from live schema.json (70 paths, costs, budget). -- [x] Tech stack pinned: Go 1.23 backend (chi, modernc sqlite, robfig/cron, gofpdf) + SolidJS + StyleX frontend (Vite, Chart.js). +- [x] Tech stack pinned: Go 1.23 backend (chi, modernc sqlite, robfig/cron, gofpdf) + SolidJS + typed-CSS frontend (Vite, Chart.js). diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..89b6993 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,83 @@ +// FlowSight server: HTTP API + ingestion scheduler in one process. +// Offline without SECTORS_API_KEY (serves seed data); live with it. +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "flowsight/internal/api" + "flowsight/internal/config" + "flowsight/internal/sectors" + "flowsight/internal/store" +) + +func main() { + cfg := config.Load() + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil { + log.Fatalf("server: mkdir data: %v", err) + } + db, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("server: open db: %v", err) + } + defer db.Close() + cache := store.NewCache(cfg.RedisURL) + sectorsClient := sectors.New(cfg.SectorsBaseURL, cfg.SectorsAPIKey) + + // Historical-replay seed keeps every screen alive with no key. + if db.NeedsSeed() { + seeded := false + for _, dir := range []string{"tests/fixtures", "../tests/fixtures", "backend/tests/fixtures"} { + st, err := db.SeedFromDir(dir, cfg.DemoUserKey) + if err == nil { + log.Printf("server: seeded %d snapshots from %s", st.Snapshots, dir) + seeded = true + break + } + } + if !seeded { + if ex, err := os.Executable(); err == nil { + dir := filepath.Join(filepath.Dir(ex), "fixtures") + if st, err := db.SeedFromDir(dir, cfg.DemoUserKey); err == nil { + log.Printf("server: seeded %d snapshots from %s", st.Snapshots, dir) + } else { + log.Printf("server: no seed bundle found (tried tests/fixtures, binary dir): %v", err) + } + } + } + } + // Ensure the demo watchlist exists even without a seed bundle. + if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 { + for _, t := range cfg.Watchlist { + _ = db.AddWatch(cfg.DemoUserKey, t) + } + } + + srv := api.New(cfg, db, cache, sectorsClient) + srv.Sched.Start() + defer srv.Sched.Stop() + + httpSrv := &http.Server{Addr: ":" + cfg.Port, Handler: srv.Router()} + go func() { + log.Printf("server: listening :%s (sectors key: %v, redis: %v)", + cfg.Port, cfg.HasSectorsKey(), cache.HasRedis()) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: listen: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + log.Print("server: stopped") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c4b3223 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,34 @@ +module flowsight + +go 1.23.0 + +toolchain go1.23.12 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.25.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/jung-kurt/gofpdf v1.16.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/redis/go-redis/v9 v9.12.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.21.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..220bbd3 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,65 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= +modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/backend/internal/agents/agent.go b/backend/internal/agents/agent.go new file mode 100644 index 0000000..383e9d8 --- /dev/null +++ b/backend/internal/agents/agent.go @@ -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} +} diff --git a/backend/internal/agents/agents_test.go b/backend/internal/agents/agents_test.go new file mode 100644 index 0000000..c7c8f56 --- /dev/null +++ b/backend/internal/agents/agents_test.go @@ -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) + } +} diff --git a/backend/internal/agents/broker_intel.go b/backend/internal/agents/broker_intel.go new file mode 100644 index 0000000..23fd97f --- /dev/null +++ b/backend/internal/agents/broker_intel.go @@ -0,0 +1,132 @@ +package agents + +import ( + "context" + "fmt" + "sort" + "strings" + + "flowsight/internal/model" + "flowsight/internal/sectors" +) + +// AnalyzeBrokerIntel (A2) classifies broker behavior and emits sector +// rotation on week-over-week sign flips with evidence rows. +func AnalyzeBrokerIntel(ctx context.Context, d Deps, ticker string) model.AgentResult { + _ = ctx + ticker = strings.ToUpper(ticker) + res := model.AgentResult{Summary: "no broker snapshots available"} + + var registry []sectors.BrokerRegistryRow + regDate, regOK := payload(d.DB, "IDX", "brokers-registry", ®istry) + var top struct { + Date string `json:"date"` + Results []sectors.TopBrokerRow `json:"results"` + } + topDate, topOK := payload(d.DB, "IDX", "brokers-top", &top) + if !regOK && !topOK { + return res + } + if regOK { + res.Citations = append(res.Citations, model.Cite("v2/brokers/", "IDX", regDate)) + } + if topOK { + res.Citations = append(res.Citations, model.Cite("v2/brokers/top/", "IDX", topDate)) + } + + byCode := map[string]sectors.BrokerRegistryRow{} + for _, r := range registry { + byCode[r.Code] = r + } + accum, distrib := 0, 0 + var lines []string + for _, b := range top.Results { + row := byCode[b.BrokerCode] + origin := "domestic" + cohort := "unknown" + if row.IsForeign { + origin = "foreign" + } + if row.Cohort != nil && *row.Cohort != "" { + cohort = *row.Cohort + } + class := "neutral" + switch { + case b.Net > 0: + class, accum = "accumulating", accum+1 + case b.Net < 0: + class, distrib = "distributing", distrib+1 + } + if len(lines) < 5 { + lines = append(lines, fmt.Sprintf("%s (%s/%s) %s %s", + b.BrokerCode, origin, cohort, class, fmtIDR(float64(b.Net)))) + } + } + total := accum + distrib + score := 0.0 + if total > 0 { + score = float64(accum-distrib) / float64(total) * 100 + } + res.Score = clampScore(score, -100, 100) + + // Rotation: week-over-week sign flip on stored sector nets. + var flow struct { + Week string `json:"week"` + Current map[string]float64 `json:"current"` + Previous map[string]float64 `json:"previous"` + } + flowDate, flowOK := payload(d.DB, "IDX", "sector-flow", &flow) + rotFrom, rotTo, rotDelta := "", "", 0.0 + if flowOK { + res.Citations = append(res.Citations, model.Cite("v2/subsector/report/", "IDX", flowDate)) + type flip struct { + sector string + delta float64 + } + var flips []flip + for s, cur := range flow.Current { + prev := flow.Previous[s] + if prev < 0 && cur > 0 { + flips = append(flips, flip{s, cur - prev}) + } + } + var outflows []flip + for s, cur := range flow.Current { + prev := flow.Previous[s] + if prev > 0 && cur < 0 { + outflows = append(outflows, flip{s, prev - cur}) + } + } + sort.Slice(flips, func(i, j int) bool { return flips[i].delta > flips[j].delta }) + sort.Slice(outflows, func(i, j int) bool { return outflows[i].delta > outflows[j].delta }) + if len(flips) > 0 && len(outflows) > 0 { + rotFrom, rotTo, rotDelta = outflows[0].sector, flips[0].sector, flips[0].delta + res.Flags = append(res.Flags, "sector-rotation") + } + } + + res.Values = []model.Value{{ + Label: "broker behavior", + Display: fmt.Sprintf("%d accumulating vs %d distributing", accum, distrib), + Citations: res.Citations, + }, { + Label: "top brokers", + Display: strings.Join(lines, "; "), + Citations: res.Citations, + }} + if rotFrom != "" { + res.Values = append(res.Values, model.Value{ + Label: "sector rotation", + Display: fmt.Sprintf("%s -> %s (%s swing)", rotFrom, rotTo, fmtIDR(rotDelta)), + Citations: res.Citations, + }) + res.Summary = fmt.Sprintf("rotation %s -> %s; score %+.0f", rotFrom, rotTo, score) + } else { + res.Summary = fmt.Sprintf("no rotation flip; score %+.0f (%d vs %d)", score, accum, distrib) + } + res.Extra = map[string]any{ + "accumulating": accum, "distributing": distrib, + "rotation_from": rotFrom, "rotation_to": rotTo, "rotation_delta": rotDelta, + } + return res +} diff --git a/backend/internal/agents/catalyst.go b/backend/internal/agents/catalyst.go new file mode 100644 index 0000000..8983391 --- /dev/null +++ b/backend/internal/agents/catalyst.go @@ -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) +} diff --git a/backend/internal/agents/fundamental.go b/backend/internal/agents/fundamental.go new file mode 100644 index 0000000..d634eee --- /dev/null +++ b/backend/internal/agents/fundamental.go @@ -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 +} diff --git a/backend/internal/agents/sentiment.go b/backend/internal/agents/sentiment.go new file mode 100644 index 0000000..5f23693 --- /dev/null +++ b/backend/internal/agents/sentiment.go @@ -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 + } +} diff --git a/backend/internal/agents/smart_money.go b/backend/internal/agents/smart_money.go new file mode 100644 index 0000000..14b57a7 --- /dev/null +++ b/backend/internal/agents/smart_money.go @@ -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 +} diff --git a/backend/internal/agents/synthesizer.go b/backend/internal/agents/synthesizer.go new file mode 100644 index 0000000..88091ad --- /dev/null +++ b/backend/internal/agents/synthesizer.go @@ -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 + "]" +} diff --git a/backend/internal/agents/technical.go b/backend/internal/agents/technical.go new file mode 100644 index 0000000..6c28a26 --- /dev/null +++ b/backend/internal/agents/technical.go @@ -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 +} diff --git a/backend/internal/alerts/evaluate.go b/backend/internal/alerts/evaluate.go new file mode 100644 index 0000000..cf3cd78 --- /dev/null +++ b/backend/internal/alerts/evaluate.go @@ -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), ¬ices) == 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 + }()) +} diff --git a/backend/internal/alerts/evaluate_test.go b/backend/internal/alerts/evaluate_test.go new file mode 100644 index 0000000..e1f5b63 --- /dev/null +++ b/backend/internal/alerts/evaluate_test.go @@ -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") + } +} diff --git a/backend/internal/alerts/notify.go b/backend/internal/alerts/notify.go new file mode 100644 index 0000000..d76a84c --- /dev/null +++ b/backend/internal/alerts/notify.go @@ -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 +} diff --git a/backend/internal/alerts/rules.go b/backend/internal/alerts/rules.go new file mode 100644 index 0000000..6d2e4de --- /dev/null +++ b/backend/internal/alerts/rules.go @@ -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"`) +} diff --git a/backend/internal/alerts/rules_test.go b/backend/internal/alerts/rules_test.go new file mode 100644 index 0000000..a7f923d --- /dev/null +++ b/backend/internal/alerts/rules_test.go @@ -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") + } +} diff --git a/backend/internal/api/alerts.go b/backend/internal/api/alerts.go new file mode 100644 index 0000000..81ed9ef --- /dev/null +++ b/backend/internal/api/alerts.go @@ -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}) +} diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..acc5ffd --- /dev/null +++ b/backend/internal/api/api_test.go @@ -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) + } +} diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go new file mode 100644 index 0000000..43739a3 --- /dev/null +++ b/backend/internal/api/chat.go @@ -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] +} diff --git a/backend/internal/api/destinations.go b/backend/internal/api/destinations.go new file mode 100644 index 0000000..c493ef5 --- /dev/null +++ b/backend/internal/api/destinations.go @@ -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 "" +} diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go new file mode 100644 index 0000000..5bfcba5 --- /dev/null +++ b/backend/internal/api/flow.go @@ -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])}, + }) +} diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go new file mode 100644 index 0000000..f06cfbe --- /dev/null +++ b/backend/internal/api/health.go @@ -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}) +} diff --git a/backend/internal/api/interrogate.go b/backend/internal/api/interrogate.go new file mode 100644 index 0000000..866a876 --- /dev/null +++ b/backend/internal/api/interrogate.go @@ -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) +} diff --git a/backend/internal/api/portfolio.go b/backend/internal/api/portfolio.go new file mode 100644 index 0000000..3122bf5 --- /dev/null +++ b/backend/internal/api/portfolio.go @@ -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 +} diff --git a/backend/internal/api/report.go b/backend/internal/api/report.go new file mode 100644 index 0000000..adb22d0 --- /dev/null +++ b/backend/internal/api/report.go @@ -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+`"}`) +} diff --git a/backend/internal/api/routines.go b/backend/internal/api/routines.go new file mode 100644 index 0000000..5efb38c --- /dev/null +++ b/backend/internal/api/routines.go @@ -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}) +} diff --git a/backend/internal/api/screen.go b/backend/internal/api/screen.go new file mode 100644 index 0000000..8579823 --- /dev/null +++ b/backend/internal/api/screen.go @@ -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)) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go new file mode 100644 index 0000000..0bad1de --- /dev/null +++ b/backend/internal/api/server.go @@ -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}}) +} diff --git a/backend/internal/api/stream.go b/backend/internal/api/stream.go new file mode 100644 index 0000000..dec4234 --- /dev/null +++ b/backend/internal/api/stream.go @@ -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() + } + } +} diff --git a/backend/internal/api/watchlist.go b/backend/internal/api/watchlist.go new file mode 100644 index 0000000..8486041 --- /dev/null +++ b/backend/internal/api/watchlist.go @@ -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}) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..696cdb2 --- /dev/null +++ b/backend/internal/config/config.go @@ -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, + } +} diff --git a/backend/internal/llm/llm.go b/backend/internal/llm/llm.go new file mode 100644 index 0000000..574e522 --- /dev/null +++ b/backend/internal/llm/llm.go @@ -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: