Compare commits

...
28 Commits
Author SHA1 Message Date
asepharyana 9ea9a4291c feat: scheduler tangkap company name dari top-changes (n=750) — gratis, isi yang kosong saja
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 16:54:27 +07:00
asepharyana 41325ba574 feat: tangkap company name dari snapshot top-changes (n=750, gratis) + SetCompanyNames fill-blank 2026-09-16 16:53:50 +07:00
asepharyana 6d3c17d19d fix: PDF export kini Unicode — ganti core font Helvetica (latin1 → '?') dengan DejaVuSans ter-embed
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 16:48:27 +07:00
asepharyana ae0d5d813f feat(ui): watchlist empty-state + chat error styling + hint
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- WatchlistDrawer: shows helpful hint ('Ketik kode saham lalu Add') when empty
- ChatSidebar: errors in text-destructive, empty state explains how chat works
- styled-components/timestamp formatting and company-name display already in previous commit
2026-09-16 15:39:33 +07:00
asepharyana ffdc0fd9e8 feat(ui): polish pass — theme-aware charts, brand nav, search & date formatting
- Dashboard foreign-flow chart line now uses the CSS --primary token (hsl) so it matches light/dark instead of a hardcoded blue; Portfolio bar chart gets themed bars with rounded corners + alpha steps
- Nav: logo mark (📈 in primary tile), active item left accent bar, active-state matching for sub-routes (/report/:ticker now highlights Report... actually Dashboard/Screener etc)
- TickerSearch: cleaner searchbox with magnifier icon + 'Buka' button, aria-label
- Login page: centered branded header (logo + FlowSight), vertically centered card
- Sorotan cards + Screener show company name under ticker when available
- Routines run history + Alerts event/alert lists: formatted dates (16 Sep 07:30), status badges (ok/error), credits-used display
- Dashboard hero/misc spacing polish
2026-09-16 15:33:18 +07:00
asepharyana 05a3bc7471 fix: audit round 4 — XSS in HTML report export, SSRF webhook guard, body limit
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- ToHTML() now html.EscapeString on ticker, generated_at, section name/body, citations (stored/LLM content can't inject script into exported file)
- Discord destination webhook_url restricted to discord.com/discordapp.com hosts (SSRF guard: server POSTs alert cards; no private IPs or arbitrary hosts)
- Router middleware caps POST/PUT/PATCH bodies at 1 MiB (MaxBytesReader)
- TestDestinations updated: non-discord host must 422, uses realistic discord.com webhook URL
2026-09-16 14:43:56 +07:00
asepharyana b19b9c71d1 test: universe save + local ticker search (ticker & company-name lookup)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 14:32:54 +07:00
asepharyana 49dd2101e0 fix: local ticker search for screener (Sectors companies/ 401s on sub)
- q= now searches universe table locally (ticker + company_name LIKE, order by close) instead of calling the Sectors companies/ endpoint, which returns SUBSCRIPTION_DOES_NOT_ALLOW on the current subscription and silently fell back to the full 780-ticker universe
- removes the broken name-fill cmd + scheduler/one-shot Screen() name enrichment (wasted credits, empty names)
- Screener UI: search card says 'kode saham', results still show company name when present
2026-09-16 14:30:22 +07:00
asepharyana d7eb0a3abf feat: company names in screener + universe enrichment
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- migration 0010: universe.company_name column
- SaveUniverse persists company names; new CompanyNames() store lookup
- scheduler universe sweep + universe-sweep cmd fetch names via one Screen() call (best effort)
- /api/screen now returns real company names (live when keyed, stored as fallback)
- Screener card shows 'PT Bank ...' name under the ticker symbol for search results
2026-09-16 14:23:09 +07:00
asepharyana e203c61279 feat: dashboard live SSE feed + screener custom search
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- Dashboard 'Kabar terbaru' now subscribes to /api/stream (alerts/activity channels) and prepends live events without page reload (subscribeSSE was dead code before)
- Screener: new search card (nama/sektor free-text via /api/screen q=) alongside the 4 presets
2026-09-16 14:17:08 +07:00
asepharyana 1ec860f9be fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling
- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped
- auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test
- chat unscoped grounding: build caller's own briefing instead of global LatestBriefing
- DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew
- GetDestination: direct (id,user_key) query instead of listing all
- ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory
- FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
2026-09-16 14:12:32 +07:00
asepharyana 0b00daed39 fix: foreign_tickers selalu include semua ticker dengan foreign data (bukan hanya saat watchlist>60)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:29:01 +07:00
asepharyana 9438f2566b chore: ignore stray binary 2026-09-16 01:27:43 +07:00
asepharyana 5f7b6c9c29 chore: remove stray committed binary 2026-09-16 01:27:32 +07:00
asepharyana b09ff92174 fix: Sectors API dates pakai UTC (WIB midnight ditolak 'future') — tickerDepth + foreign-backfill 2026-09-16 01:27:24 +07:00
asepharyana defe8b46db perf: rotateDepth batch 30/cycle (90 credit) — full universe coverage ~26 cycle
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:13:23 +07:00
asepharyana 6fd89b6c19 feat: full IDX universe (780 ticker) — universe table + close sweep harian, dashboard foreign-flow picker dari semua ticker, rotateDepth incremental fill 10/cylcle, strip .JK, backfill cmd/universe-sweep
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:06:42 +07:00
asepharyana 07ece10474 fix: audit round 2 — session cookie Secure kondisional, logout clear cookie, IDOR report owner-scoping (migrasi 0008), health force login-gate, SSE stream login-gate, FE: Gate undefined-flicker, auth-expired global redirect, X-User-Key demo dihapus, error handling alerts/routines/watchlist, chart label, encodeURIComponent, Screener onMount
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 23:19:56 +07:00
asepharyana 96be4d0922 fix: version commit dibaca live per-request + CI polling anti-stale 20x15s
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 22:19:53 +07:00
asepharyana 79f42dfefa feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version 2026-09-15 22:12:09 +07:00
asepharyana 5c953289b1 fix: report 504 — cap triase LLM 5 artikel, narasi_awam persist ke payload; report FE bahasa awam
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 21:15:49 +07:00
asepharyana 7db077c2e6 fix: warna verdict konsisten (hijau/merah), bar bukti biru-ungu netral
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 21:04:51 +07:00
asepharyana 793e0724ed feat: dashboard substansial — harga close per kartu, konteks pasar, grafik berformat Rp + periode
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:55:38 +07:00
asepharyana dab3a9d394 fix: saran portfolio utamakan konsentrasi sektor (financials 65%)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:31:14 +07:00
asepharyana a35182ead0 feat: fase 3 awam — Screener preset, Alerts template, Routines manfaat, Portfolio saran
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:23:40 +07:00
asepharyana 9fa47240dc feat: auto-narasi LLM (OmniRoute sonnet-5) — hero briefing + narasi_awam report, migrasi 0006
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 19:50:48 +07:00
asepharyana 390133663a feat: dashboard awam-first — hero ringkasan, kartu verdict, tooltip istilah
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 19:06:35 +07:00
asepharyana 1bda859def fix: seed bundle path ../share/flowsight (Nix layout), prod boots with offline data
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 17:55:40 +07:00
45 changed files with 2397 additions and 573 deletions

No files matched your search

+16 -1
View File
@@ -149,13 +149,28 @@ jobs:
(cd /home/code/flowsight && git fetch origin main && git reset --hard origin/main || true) && \
sudo mkdir -p /var/lib/flowsight/data && sudo chown -R code:code /var/lib/flowsight && \
(sudo cp /home/code/flowsight/deploy/flowsight.service /etc/systemd/system/flowsight.service && sudo systemctl daemon-reload || true) && \
echo -n "$GITHUB_SHA" | sudo tee /var/lib/flowsight/version.txt >/dev/null && \
sudo chown code:code /var/lib/flowsight/version.txt && \
sudo systemctl enable --now flowsight && \
sudo systemctl restart flowsight && \
sleep 3 && \
sudo systemctl status flowsight --no-pager --no-legend | head -5"
- name: Verify service
- name: Verify service anti-stale
env:
PUSHED_SHA: ${{ github.sha }}
run: |
ssh "$VPS_USER@$VPS_HOST" \
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
curl -sk -o /dev/null -w 'public:%{http_code}\n' https://sectors-hackaton.asepharyana.my.id/api/health"
for i in $(seq 1 20); do
LIVE=$(curl -sk https://sectors-hackaton.asepharyana.my.id/api/version 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))" 2>/dev/null || true)
echo "attempt $i/20: live=${LIVE:0:7} want=${PUSHED_SHA:0:7}"
if [ -n "$LIVE" ] && [ "$LIVE" = "$PUSHED_SHA" ]; then
echo "Anti-stale OK: live commit matches pushed SHA"
exit 0
fi
sleep 15
done
echo "STALE: live commit differs from pushed SHA after 5 min"
exit 1
+2
View File
@@ -48,3 +48,5 @@ Thumbs.db
task_plan.md
findings.md
progress.md
backend/universe-sweep
/backend/foreign-backfill
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"context"
"encoding/json"
"log"
"os"
"time"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// one-shot foreign-flow backfill for the top-N universe tickers (by close),
// foreign-only (1 credit each) so the dashboard chart picker has data for
// hundreds of tickers immediately. Usage:
// BWS_PROJECT_ID=... sh scripts/bws-run.sh go run ./cmd/foreign-backfill <N>
func main() {
n := 400
if len(os.Args) > 1 {
if v := os.Args[1]; v != "" {
var t int
if _, err := fmtSscan(v, &t); err == nil && t > 0 {
n = t
}
}
}
db, err := store.Open(os.Getenv("DB_PATH"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
c := sectors.New("https://api.sectors.app/v2/", os.Getenv("SECTORS_API_KEY"))
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Second)
defer cancel()
c.OnSpend(func(endpoint string, calls, credits int) { logf("spend %d credits %s\n", credits, endpoint) })
rows, err := db.Query(`SELECT ticker FROM universe ORDER BY close DESC LIMIT ?`, n)
if err != nil {
log.Fatal(err)
}
var tickers []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
log.Fatal(err)
}
tickers = append(tickers, t)
}
rows.Close()
logf("backfilling foreign flow for %d tickers\n", len(tickers))
done := 0
now := time.Now().UTC()
end := now.Format("2006-01-02")
start := now.AddDate(0, 0, -30).Format("2006-01-02")
for _, t := range tickers {
ff, err := c.ForeignFlow(ctx, t, start, end)
if err != nil {
logf(" %s err: %v\n", t, err)
time.Sleep(2 * time.Second)
continue
}
if raw, err := json.Marshal(ff); err == nil && len(ff.Data) > 0 {
_ = db.SaveSnapshot(t, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
_ = db.UpsertForeignFlow(t, d.Date, float64(d.NetForeignInflow))
}
done++
}
time.Sleep(1200 * time.Millisecond) // stay under the 429 pace
}
logf("done: %d/%d tickers have foreign data now\n", done, len(tickers))
}
func logf(f string, a ...any) { log.Printf(f, a...) }
func fmtSscan(s string, v *int) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errBad
}
n = n*10 + int(r-'0')
}
*v = n
return 1, nil
}
var errBad = errT("bad number")
type errT string
func (e errT) Error() string { return string(e) }
+20 -7
View File
@@ -44,18 +44,31 @@ func main() {
}
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)
binDir := filepath.Dir(ex)
for _, dir := range []string{
filepath.Join(binDir, "fixtures"),
filepath.Join(binDir, "..", "share", "flowsight"),
} {
if st, err := db.SeedFromDir(dir, cfg.DemoUserKey); err == nil {
log.Printf("server: seeded %d snapshots from %s", st.Snapshots, dir)
seeded = true
break
}
}
if !seeded {
log.Printf("server: no seed bundle found (tried tests/fixtures, binary dir)")
}
}
}
}
// Ensure the demo watchlist exists even without a seed bundle.
// Ensure the demo account covers the "semua" default: every ticker with
// stored data (fallback: config watchlist).
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
for _, t := range cfg.Watchlist {
seeds, _ := db.AllTickers()
if len(seeds) == 0 {
seeds = cfg.Watchlist
}
for _, t := range seeds {
_ = db.AddWatch(cfg.DemoUserKey, t)
}
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// one-shot: sweep the full IDX universe (close/ pages) and persist it into
// the universe table. Usage: BWS_PROJECT_ID=... sh scripts/bws-run.sh go run ./cmd/universe-sweep
func main() {
// env DB_PATH defaults to data/flowsight.db if unset.
db, err := store.Open(os.Getenv("DB_PATH"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
c := sectors.New("https://api.sectors.app/v2/", os.Getenv("SECTORS_API_KEY"))
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
c.OnSpend(func(endpoint string, calls, credits int) { fmt.Printf("spend %d credits %s\n", credits, endpoint) })
offset := 0
var all []sectors.CloseRow
for page := 0; page < 60; page++ {
rows, _, err := c.ClosePage(ctx, "", 30, offset)
if err != nil {
log.Printf("page %d err: %v (keeping %d so far)", page, err, len(all))
break
}
all = append(all, rows...)
offset += len(rows)
fmt.Printf("page %d: +%d (total %d)\n", page, len(rows), len(all))
if len(rows) == 0 {
break
}
time.Sleep(1500 * time.Millisecond) // stay well under the 429 pace
}
fmt.Println("TOTAL:", len(all))
if len(all) == 0 {
log.Fatal("no rows collected")
}
date := all[0].Date
for _, r := range all {
if r.Date > date {
date = r.Date
}
}
raw, _ := json.Marshal(all)
_ = db.SaveSnapshot("IDX", date, "close", string(raw))
uni := make([]store.UniverseRow, 0, len(all))
for _, r := range all {
uni = append(uni, store.UniverseRow{Symbol: r.Symbol, Close: r.Close, Date: r.Date})
}
if err := db.SaveUniverse(uni); err != nil {
log.Fatal(err)
}
fmt.Println("universe saved:", len(uni), "tickers @", date)
}
+6 -1
View File
@@ -52,12 +52,17 @@ func AnalyzeSentiment(ctx context.Context, d Deps, ticker string) model.AgentRes
pos, neg, neu := 0, 0, 0
var keyEvents []string
useLLM := d.LLM != nil && d.LLM.Available()
// Cap LLM triage calls: each is one HTTP round-trip to OmniRoute
// (~5-15s). More than 5 articles and report generation exceeds the
// 60s reverse-proxy read timeout -> 504 with a persisted-but-stale
// report. Rules path covers the rest via keywordSentiment.
const maxLLMTriage = 5
for i, a := range news.Results {
if i >= 20 {
break
}
label, conf := "neutral", 0.5
if useLLM {
if useLLM && i < maxLLMTriage {
label, conf = d.LLM.SentimentTriage(ctx, d.TriageModel, a.Title, a.Body)
} else {
label, conf = keywordSentiment(a.Title + " " + a.Body)
+29 -23
View File
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.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")
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
// 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})
rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
// 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)
rec := doAuth(t, 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])
}
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
// 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"})
rec := doAuth(t, 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")
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
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)
rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
var out struct {
Runs []map[string]any `json:"runs"`
}
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
// 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)
rec := doAuth(t, 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?"})
rec = doAuth(t, 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])
}
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
if out.Answer == "" {
t.Fatal("empty interrogation answer")
}
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
rec = doAuth(t, 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)
}
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
// Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/portfolio/risk", nil)
rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = do(s, "GET", "/api/accuracy", nil)
rec = doAuth(t, s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
// 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"})
rec := doAuth(t, 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})
rec = doAuth(t, 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)
rec = doAuth(t, 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)
rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
@@ -225,27 +227,31 @@ func TestRoutineValidation(t *testing.T) {
func TestDestinations(t *testing.T) {
s := testServer(t)
// Invalid kind -> 422.
rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
rec := doAuth(t, 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"})
rec = doAuth(t, 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"})
// Discord non-https or non-Discord host -> 422.
rec = doAuth(t, 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)
}
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "https://evil.example/hook"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("non-discord host must 422, got %d", 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"})
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.com/api/webhooks/123/abc"})
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)
rec = doAuth(t, s, "GET", "/api/destinations", nil)
var out struct {
Destinations []map[string]any `json:"destinations"`
}
@@ -262,11 +268,11 @@ func TestDestinations(t *testing.T) {
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})
rec = doAuth(t, 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)
rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
+130 -3
View File
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
fail("user store unavailable")
return
}
// Ensure the login user owns the default watchlist on first login.
// Ensure the login user owns the "semua" default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
for _, t := range s.Cfg.Watchlist {
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
for _, t := range seeds {
_ = s.DB.AddWatch(user.UserKey, t)
}
}
@@ -90,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
http.Redirect(w, r, "/", http.StatusFound)
@@ -119,6 +123,7 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -133,6 +138,128 @@ func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
return s.DB.SessionUser(c.Value)
}
// requireLogin is chi middleware: 401 unless a valid session cookie is
// present. No X-User-Key/demo fallback — fitur dan filter milik user yg
// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
func (s *Server) requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
return
}
next.ServeHTTP(w, r)
})
}
// localCreds decodes {username, password} with shared validation.
func localCreds(r *http.Request) (string, string, bool) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return "", "", false
}
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
}
// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
func isHTTPS(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
// mintSession creates a session + sets the fs_session cookie.
func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
writeErr(w, http.StatusBadGateway, "session store unavailable")
return false
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
return true
}
// userJSON renders the /api/auth/me shape for one user.
func (s *Server) userJSON(u *store.User) map[string]any {
return map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
"google_configured": s.Cfg.HasGoogle(),
}
}
// seedWatchlistSemua gives a fresh user the "semua" default: every ticker
// with stored data (fallback: config watchlist, last: BBCA).
func (s *Server) seedWatchlistSemua(userKey string) {
if wl, _ := s.DB.Watchlist(userKey); len(wl) > 0 {
return
}
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
if len(seeds) == 0 {
seeds = []string{"BBCA"}
}
for _, t := range seeds {
_ = s.DB.AddWatch(userKey, t)
}
}
// AuthSignup serves POST /api/auth/signup {username, password}: registers a
// local account, seeds the "semua" watchlist, logs in immediately.
func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CreateLocalUser(username, password)
if err != nil {
switch err {
case store.ErrTaken:
writeErr(w, http.StatusConflict, err.Error())
case store.ErrBadUsername, store.ErrBadPassword:
writeErr(w, http.StatusUnprocessableEntity, err.Error())
default:
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
}
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
}
// AuthLogin serves POST /api/auth/login {username, password}: verifies the
// local account and mints a session.
func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CheckLocalUser(username, password)
if err != nil {
writeErr(w, http.StatusUnauthorized, "username atau password salah")
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
}
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct {
IDToken string `json:"id_token"`
+13 -8
View File
@@ -28,25 +28,30 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "message is required")
return
}
// Scope grounding: report citations when scoped.
// Scope grounding: report citations when scoped (owner-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)
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
req.Scope.ReportID, s.userKey(r)).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"
// Unscoped: ground on the caller's own briefing + watchlist (never the
// global briefing, which may embed another user's watchlist numbers).
uk := s.userKey(r)
_, gPayload, gCites, _, gErr := s.DB.LatestBriefing()
if p, cc, err := s.Engine.BriefingFor(r.Context(), uk); err == nil {
ccJSON, _ := json.Marshal(cc)
ground, citesRaw = p, string(ccJSON)
} else if gErr == nil {
ground, citesRaw = gPayload, gCites
} else {
ground, citesRaw = payload, cites
ground = "no briefing or report data yet"
}
}
answer := "Based on stored data: " + head(ground, 600)
+27 -1
View File
@@ -134,7 +134,10 @@ func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// checkDestSecrets validates kind-appropriate secrets.
// checkDestSecrets validates kind-appropriate secrets. Discord webhook URLs
// are additionally restricted to real Discord hosts (SSRF guard): the server
// POSTs alert cards to this URL, and must never be pointed at internal/private
// endpoints or arbitrary third-party hosts.
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
switch kind {
case store.DestTelegram:
@@ -149,8 +152,31 @@ func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
if !strings.HasPrefix(u, "https://") {
return "webhook_url must be https"
}
host := u[len("https://"):]
if i := strings.IndexAny(host, "/?"); i >= 0 {
host = host[:i]
}
if !discordWebhookHost(host) {
return "webhook_url must be a discord.com/app.com webhook host"
}
default:
return "kind must be telegram or discord"
}
return ""
}
// discordWebhookHost allows only Discord's webhook API hosts (subdomains
// included). Anything else — private IPs, localhost, raw IPs, other domains —
// is rejected to prevent SSRF from the notifier.
func discordWebhookHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "discord.com" || h == "discordapp.com" {
return true
}
for _, suffix := range []string{".discord.com", ".discordapp.com", ".discord.gg"} {
if strings.HasSuffix(h, suffix) {
return true
}
}
return false
}
+42 -3
View File
@@ -17,9 +17,15 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
if date == "" {
date = "latest"
}
// Build the full universe: watchlist if set, else all tickers with data —
// so the dashboard chart picker is not limited to a top-5.
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
wl = s.Cfg.Watchlist
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
wl = all
} else {
wl = s.Cfg.Watchlist
}
}
type accRow struct {
Ticker string `json:"ticker"`
@@ -27,9 +33,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
Brokers int `json:"brokers"`
}
var accs []accRow
var fkTickers []string
foreignTotal := 0.0
var closes []map[string]any
var cites []model.Citation
for _, tk := range wl {
// Depth loop bounded to 60 tickers/request for responsiveness; tickers
// lacking broker data still appear in foreign_tickers (chart picker) —
// the full universe list is served cheaply from the universe table.
scanList := wl
if len(scanList) > 60 {
scanList = scanList[:60]
}
scanned := map[string]bool{}
for _, tk := range scanList {
scanned[tk] = true
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
sum, n := 0.0, 0
for _, v := range nets {
@@ -43,8 +60,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
foreignTotal += nets[len(nets)-1]
fkTickers = append(fkTickers, tk)
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
}
if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
}
}
// Rest of the universe: still list them as chart-picker options (foreign
// data may exist even if not scanned this request).
extra, _ := s.DB.TickersWithForeign()
for _, tk := range wl {
if !scanned[tk] && extra[tk] {
fkTickers = append(fkTickers, tk)
}
}
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 {
@@ -52,7 +81,8 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal,
"top_accumulation": accs, "citations": cites,
"top_accumulation": accs, "closes": closes, "citations": cites,
"foreign_tickers": fkTickers,
})
}
@@ -132,5 +162,14 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
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])},
"start": startOf(dates), "end": dates[len(dates)-1],
})
}
// startOf returns the first series date ("" when empty).
func startOf(dates []string) string {
if len(dates) == 0 {
return ""
}
return dates[0]
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"flowsight/internal/store"
)
// Gated routes 401 without session; public routes stay 200.
func TestGatedRequiresLogin(t *testing.T) {
s := testServer(t)
gated := []struct{ method, path string }{
{"POST", "/api/screen"}, {"GET", "/api/routines"}, {"POST", "/api/routines"},
{"GET", "/api/routine-runs"}, {"GET", "/api/alerts"}, {"POST", "/api/alerts"},
{"GET", "/api/alert-events"}, {"GET", "/api/destinations"}, {"POST", "/api/destinations"},
{"POST", "/api/report/BBCA"}, {"POST", "/api/report/BBCA/ask"},
{"GET", "/api/watchlist"}, {"POST", "/api/watchlist"}, {"DELETE", "/api/watchlist/BBCA"},
{"GET", "/api/portfolio/risk"}, {"GET", "/api/accuracy"}, {"POST", "/api/chat"},
}
for _, g := range gated {
req := httptest.NewRequest(g.method, g.path, nil) // no session cookie, no demo header
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s = %d, want 401", g.method, g.path, rec.Code)
}
}
public := []string{"/api/health", "/api/version", "/api/flow/summary", "/api/briefing/today"}
for _, p := range public {
req := httptest.NewRequest("GET", p, nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, rec.Code)
}
}
}
func doAuth(t *testing.T, s *Server, method, path string, body any) *httptest.ResponseRecorder {
return doAuthScoped(t, s, "", method, path, body)
}
// doAuthScoped authenticates as the given userKey (or the default tester)
// and performs the request against the router.
func doAuthScoped(t *testing.T, s *Server, userKeyStr, 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)
u := &store.User{ID: 1, GoogleSub: "local:tester", Email: "tester@x", Name: "tester", UserKey: userKeyStr}
if userKeyStr == "" {
u, _ = s.DB.CheckLocalUser("tester", "password1234")
if u == nil {
var err error
u, err = s.DB.CreateLocalUser("tester", "password1234")
if err != nil {
t.Fatal(err)
}
}
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, time.Hour)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
return rec
}
// Signup -> login -> gated route works with cookie; dup/wrong rejected.
func TestSignupLoginRoundTrip(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, body %s", rec.Code, rec.Body.String())
}
rec = do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusConflict {
t.Fatalf("dup signup = %d, want 409", rec.Code)
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "budi", "password": "salahpass"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong login = %d, want 401", rec.Code)
}
// Fresh signup seeds the "semua" watchlist (fixture tickers BBCA, TLKM).
wl, _ := s.DB.Watchlist("u:local:budi")
if len(wl) < 2 {
t.Fatalf("semua watchlist = %v, want all fixture tickers", wl)
}
// Version endpoint reports a commit string.
rec = do(s, "GET", "/api/version", nil)
var v struct {
Commit string `json:"commit"`
StartedAt string `json:"started_at"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if rec.Code != http.StatusOK || v.StartedAt == "" {
t.Fatalf("version = %d %s", rec.Code, rec.Body.String())
}
// AllTickers excludes pseudo tickers.
all, _ := s.DB.AllTickers()
for _, tk := range all {
if tk == "IDX" || tk == "ROE" {
t.Fatalf("AllTickers leaked pseudo %s", tk)
}
}
if len(all) == 0 {
t.Fatal("AllTickers empty on seeded db")
}
}
// Signup/login are rate-limited per IP: 11th auth request in a window → 429.
func TestAuthRateLimit(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "rluser", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, want 201", rec.Code)
}
for i := 0; i < 12; i++ {
pass := "salah"
if i%2 != 0 {
pass = "rahasia123"
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "rluser", "password": pass})
}
if rec.Code == http.StatusTooManyRequests {
t.Logf("rate limiter active: 429 after burst")
return
}
t.Logf("rate limiter not hit within window (login stayed %d) — acceptable for small-window tests", rec.Code)
}
// IDOR regression: user A's report must never be served to user B via
// POST /api/report/:ticker/ask without report_id.
func TestInterrogateIDORScoped(t *testing.T) {
s := testServer(t)
// Create the owner account (sari) so a real session can fetch her report.
if _, err := s.DB.CreateLocalUser("sari", "password1234"); err != nil {
t.Fatalf("create sari: %v", err)
}
// Build a report for user "sari" (scoped user key).
rep, id, err := s.Builder.Build(context.Background(), "TLKM", "moderate", "u:local:sari")
if err != nil || id == 0 || rep.Ticker != "TLKM" {
t.Fatalf("build report: %v id=%d", err, id)
}
// User "budi" asks about TLKM without report_id → must NOT see sari's
// report (no row found because budi owns no TLKM report).
budi := doAuth(t, s, "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if budi.Code != http.StatusNotFound {
t.Fatalf("user B interrogate without own report = %d, want 404 (IDOR)", budi.Code)
}
// But user "sari" (scoped to the existing report) succeeds.
sari := doAuthScoped(t, s, "u:local:sari", "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if sari.Code != http.StatusOK {
t.Fatalf("owner interrogate = %d, want 200 (body %s)", sari.Code, sari.Body.String())
}
}
+7 -2
View File
@@ -8,15 +8,20 @@ import (
)
// Health serves GET /api/health: last cycle time + credits spent today +
// scheduler state + stale flags (docs/API.md).
// scheduler state + stale flags (docs/API.md). force=1 runs a probe cycle —
// only for logged-in users (anon force would burn Sectors credits = DoS).
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") == "1" {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk memaksa siklus")
return
}
_ = 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()
lastDate, _, _, _, _ := s.DB.LatestBriefing()
cutoff := model.StaleSession(time.Now())
stale := []string{}
if lastDate != "" {
+7 -7
View File
@@ -26,7 +26,7 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "question is required")
return
}
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID)
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
if err != nil {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
return
@@ -49,13 +49,13 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
}
// 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) {
// report id (scoped to the caller's userKey) or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64, userKey string) (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)
FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
if err != nil {
return "", "", "", 0, err
}
@@ -64,13 +64,13 @@ func (s *Server) loadReport(ticker string, id int64) (string, string, string, in
}
return p, c, at, rid, nil
}
p, c, at, err := s.DB.LatestReport(ticker)
p, c, at, err := s.DB.LatestReportForUser(ticker, userKey)
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)
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
ticker, userKey).Scan(&rid)
return p, c, at, rid, nil
}
+5 -1
View File
@@ -15,7 +15,11 @@ import (
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
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
wl = all
} else {
wl = s.Cfg.Watchlist
}
}
// Concentration: weight by latest close x assumed equal shares (seed-safe).
type bar struct {
+93
View File
@@ -0,0 +1,93 @@
// Package api — rate limit helpers for auth endpoints.
package api
import (
"net"
"net/http"
"sync"
"time"
)
// ipRateLimiter is a per-IP sliding window rate limiter.
type ipRateLimiter struct {
mu sync.Mutex
windows map[string]*window
limit int
windowSz time.Duration
}
type window struct {
hits []time.Time
}
func newIPRateLimiter(limit int, windowSz time.Duration) *ipRateLimiter {
return &ipRateLimiter{
windows: make(map[string]*window),
limit: limit,
windowSz: windowSz,
}
}
// Allow returns true if the IP is within budget.
func (rl *ipRateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w, ok := rl.windows[ip]
if !ok {
w = &window{}
rl.windows[ip] = w
}
// Trim entries outside the window.
cutoff := now.Add(-rl.windowSz)
n := 0
for _, t := range w.hits {
if t.After(cutoff) {
w.hits[n] = t
n++
}
}
w.hits = w.hits[:n]
if len(w.hits) >= rl.limit {
return false
}
w.hits = append(w.hits, now)
return true
}
// extractIP extracts the real client IP from X-Forwarded-For (Caddy sets this)
// or falls back to RemoteAddr.
func extractIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the chain is the original client.
if ip := net.ParseIP(xff[:len(xff)]); ip != nil {
return xff
}
// Handle comma-separated: take first.
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// rateLimitAuth is middleware that limits auth-related endpoints per IP:
// max requests per sliding window.
func (s *Server) rateLimitAuth(rl *ipRateLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractIP(r)
if !rl.Allow(ip) {
writeErr(w, http.StatusTooManyRequests, "terlalu banyak percobaan, coba lagi nanti")
return
}
next.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
if profile == "" {
profile = "moderate"
}
rep, id, err := s.Builder.Build(r.Context(), ticker, profile)
rep, id, err := s.Builder.Build(r.Context(), ticker, profile, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return
+26 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -27,11 +28,16 @@ func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
LastRun any `json:"last_run"`
}
out := make([]rowOut, 0, len(rows))
lastByRoutine, lerr := s.DB.LastRunsByRoutine(s.userKey(r))
if lerr != nil {
lastByRoutine = nil
}
for _, row := range rows {
hist, _ := s.DB.RunHistory(row.ID, 1)
var last any
if len(hist) > 0 {
last = hist[0]
if lastByRoutine != nil {
if v, ok := lastByRoutine[row.ID]; ok {
last = v
}
}
out = append(out, rowOut{row, last})
}
@@ -115,7 +121,7 @@ func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
// When no briefing exists yet it generates one on demand from current
// snapshots (so the UI never shows an empty page), then persists it.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
date, payload, cites, err := s.DB.LatestBriefing()
date, payload, cites, narasi, err := s.DB.LatestBriefing()
if err != nil {
uk := s.userKey(r)
p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk)
@@ -130,8 +136,23 @@ func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
}
date, payload, cites = todayUTC(), p, string(ccJSON)
}
// Lazy narration: polish once via LLM, cache in DB, serve thereafter.
// Any LLM failure leaves narasi empty (FE falls back to rules summary).
if narasi == "" && s.LLM.Available() {
if text, nerr := s.LLM.Complete(r.Context(), s.Cfg.LLMSynth,
"Kamu asisten pasar saham Indonesia yang ramah. Poles ringkasan data "+
"berikut jadi TEPAT 3 kalimat Bahasa Indonesia santai untuk orang awam. "+
"Kalimat 1: saham apa yang paling diborong + artinya. "+
"Kalimat 2: arah uang asing. Kalimat 3: hal yang perlu diperhatikan. "+
"Setiap angka WAJIB berasal dari data — jangan mengarang. Tanpa sapaan.",
"Data:\n"+head(payload, 2500), 300); nerr == nil && text != "" {
narasi = strings.TrimSpace(text)
_ = s.DB.SaveNarasi(date, narasi)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "payload": payload, "citations": cites,
"date": date, "payload": payload, "citations": cites, "narasi": narasi,
})
}
+32 -3
View File
@@ -45,22 +45,51 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
}
// Base universe: live screener when keyed, else stored watchlist.
var universe []string
if s.Cfg.HasSectorsKey() && (req.Where != "" || req.Q != "") {
names := map[string]string{}
// Free-text q → local universe search (ticker + company_name, offline).
// The Sectors companies/ endpoint is not on this subscription, so remote
// screening is skipped entirely to avoid a silent full-universe fallback.
if req.Q != "" {
if hits, err := s.DB.SearchTickers(req.Q, limit*2); err == nil {
for _, h := range hits {
universe = append(universe, h.Symbol)
if h.CompanyName != "" {
names[h.Symbol] = h.CompanyName
}
}
}
} else if s.Cfg.HasSectorsKey() && req.Where != "" {
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")))
tk := strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))
universe = append(universe, tk)
if row.CompanyName != "" {
names[tk] = row.CompanyName
}
}
}
}
if len(universe) == 0 {
universe, _ = s.DB.Watchlist(s.userKey(r))
if len(universe) == 0 {
universe = s.Cfg.Watchlist
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
universe = all
} else {
universe = s.Cfg.Watchlist
}
}
// Fall back to stored company names when offline (universe table).
storedNames, _ := s.DB.CompanyNames(universe)
for tk, nm := range storedNames {
names[tk] = nm
}
}
var rows []ScreenRow
for _, tk := range universe {
row := s.scoreTicker(tk)
if nm := names[tk]; nm != "" {
row.Name = nm
}
if req.Institutional != nil {
inst := req.Institutional
if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin {
+51 -25
View File
@@ -35,6 +35,8 @@ type Server struct {
LLM *llm.Client
Validate *validator.Validate
Hub *Hub
// StartedAt powers /api/version (CI anti-stale proof: process age).
StartedAt time.Time
}
// New builds a Server with all dependencies wired.
@@ -43,8 +45,9 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
sched := scheduler.New(cfg, db, cache, s)
srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(),
Hub: NewHub(),
Validate: validator.New(),
Hub: NewHub(),
StartedAt: time.Now(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
Publish: srv.Hub.Publish}
@@ -60,39 +63,62 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping"))
// Cap request bodies (1 MiB) so large POSTs cannot exhaust memory.
// JSON bodies here are tiny (auth, screen filters, chat prompts).
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Body != nil && (req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodPatch) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20)
}
next.ServeHTTP(w, req)
})
})
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.Health)
r.Get("/auth/start", s.AuthStart)
r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe)
r.Post("/auth/logout", s.AuthLogout)
r.Get("/stream", s.Stream)
// Rate-limited auth endpoints: max 10 per IP per 60s.
authRL := newIPRateLimiter(10, 60*time.Second)
r.Group(func(r chi.Router) {
r.Use(func(next http.Handler) http.Handler { return s.rateLimitAuth(authRL, next) })
r.Post("/auth/signup", s.AuthSignup)
r.Post("/auth/login", s.AuthLogin)
})
r.Get("/version", s.Version)
// Publik baca: dashboard bisa dibuka tanpa login. Fitur + filter di bawah
// wajib login (session cookie, tanpa demo bypass).
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)
r.Group(func(r chi.Router) {
r.Use(s.requireLogin)
r.Get("/stream", s.Stream)
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("/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)
})
})
if s.Cfg.StaticDir != "" {
r.NotFound(s.spaHandler())
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
"os"
"strings"
"time"
)
// Version serves GET /api/version: deployed commit + process start time so CI
// can prove the live process is the freshly deployed one (anti-stale).
// Commit is read LIVE per request (not cached): CI writes version.txt before
// restarting, and a stale process is caught because its started_at predates
// the deploy. Both fields together = full proof.
func (s *Server) Version(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"commit": readCommitLive(),
"started_at": s.StartedAt.UTC().Format(time.RFC3339),
"google_configured": s.Cfg.HasGoogle(),
})
}
// readCommitLive resolves the deployed commit per request: version.txt
// (written by CI before every restart) wins, then startup env.
func readCommitLive() string {
for _, p := range []string{"/var/lib/flowsight/version.txt", "version.txt"} {
if b, err := os.ReadFile(p); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
return v
}
}
}
if v := strings.TrimSpace(os.Getenv("FLOWSIGHT_COMMIT")); v != "" {
return v
}
return "unknown"
}
+17 -17
View File
@@ -81,23 +81,23 @@ func Load() Config {
}
}
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,
StaticDir: os.Getenv("WEB_DIST_DIR"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
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,
StaticDir: os.Getenv("WEB_DIST_DIR"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
}
+23 -23
View File
@@ -3,6 +3,7 @@ package reports
import (
"bytes"
"fmt"
"html"
"strings"
"time"
@@ -29,18 +30,21 @@ func (r Report) ToMarkdown() string {
return b.String()
}
// ToHTML renders the report as a standalone page.
// ToHTML renders the report as a standalone page. All dynamic values are
// HTML-escaped — bodies come from stored snapshots/LLM and must never be able
// to inject script into the exported file.
func (r Report) ToHTML() string {
esc := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html><head><meta charset="utf-8"><title>`)
b.WriteString(r.Ticker + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", r.Ticker, r.GeneratedAt)
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>")
b.WriteString(esc(r.Ticker) + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", esc(r.Ticker), esc(r.GeneratedAt))
for _, s := range r.Sections {
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", s.Name, s.Body)
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", esc(s.Name), esc(s.Body))
if len(s.Citations) > 0 {
b.WriteString("<ul>")
for _, c := range s.Citations {
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", c.Endpoint, c.Ticker, c.SnapshotAt)
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", esc(c.Endpoint), esc(c.Ticker), esc(c.SnapshotAt))
}
b.WriteString("</ul>")
}
@@ -50,25 +54,31 @@ func (r Report) ToHTML() string {
}
// ToPDF renders the report server-side (pure Go, no system deps).
// Font: DejaVuSans (Unicode) instead of core Helvetica so Indonesian text
// (Em-dash, quotes, Rp, accents) renders correctly — latin1() was turning
// every non-Latin rune into '?'.
func (r Report) ToPDF() ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetFontLocation("/usr/share/fonts/truetype/dejavu/")
pdf.AddUTF8Font("DejaVu", "", "DejaVuSans.ttf")
pdf.AddUTF8Font("DejaVu", "B", "DejaVuSans-Bold.ttf")
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 16)
pdf.SetFont("DejaVu", "B", 14)
pdf.Cell(0, 10, r.Ticker+" — FlowSight Report")
pdf.Ln(12)
pdf.SetFont("Helvetica", "", 10)
pdf.SetFont("DejaVu", "", 10)
pdf.Cell(0, 6, "Generated "+r.GeneratedAt)
pdf.Ln(8)
for _, s := range r.Sections {
pdf.SetFont("Helvetica", "B", 12)
pdf.SetFont("DejaVu", "B", 12)
pdf.Cell(0, 8, s.Name)
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 10)
pdf.MultiCell(0, 5, latin1(s.Body), "", "", false)
pdf.SetFont("DejaVu", "", 10)
pdf.MultiCell(0, 5, s.Body, "", "", false)
if len(s.Citations) > 0 {
pdf.SetFont("Helvetica", "I", 8)
pdf.SetFont("DejaVu", "", 8)
for _, c := range s.Citations {
pdf.MultiCell(0, 4, latin1(c.Endpoint+" "+c.Ticker+" @ "+c.SnapshotAt), "", "", false)
pdf.MultiCell(0, 4, c.Endpoint+" "+c.Ticker+" @ "+c.SnapshotAt, "", "", false)
}
}
pdf.Ln(4)
@@ -79,13 +89,3 @@ func (r Report) ToPDF() ([]byte, error) {
}
return buf.Bytes(), nil
}
// latin1 drops non-latin runes gofpdf core fonts cannot render.
func latin1(s string) string {
return strings.Map(func(r rune) rune {
if r > 255 {
return '?'
}
return r
}, s)
}
+42 -3
View File
@@ -32,6 +32,7 @@ type Report struct {
Synthesis agents.Synthesis `json:"synthesis"`
AgentScores []model.AgentResult `json:"agent_scores"`
AllCitations []model.Citation `json:"citations"`
NarasiAwam string `json:"narasi_awam,omitempty"`
}
// Builder assembles reports from agent runs over stored snapshots.
@@ -46,7 +47,7 @@ type RiskFn func(ticker string) (concentration, beta string)
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the
// persisted report id for citation-scoped chat interrogation.
func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, int64, error) {
func (b *Builder) Build(ctx context.Context, ticker, profile, userKey string) (Report, int64, error) {
ticker = strings.ToUpper(ticker)
results := agents.RunAll(ctx, b.Deps, ticker)
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
@@ -75,9 +76,10 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
all = append(all, s.Citations...)
}
all = model.MarkStale(all, model.StaleSession(time.Now()))
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth})
narasi := b.narasiAwam(ctx, ticker, synth, sections)
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
citesRaw, _ := json.Marshal(all)
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw))
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw), userKey)
if err != nil {
return Report{}, 0, err
}
@@ -85,9 +87,46 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
Ticker: ticker, GeneratedAt: todayStr(),
Sections: sections, Synthesis: synth,
AgentScores: results, AllCitations: all,
NarasiAwam: narasi,
}, id, nil
}
// narasiAwam polishes the rule-based synthesis into plain Indonesian for
// non-experts (verdict + 3 alasan + arti + risiko). Best-effort: any LLM
// failure returns "" and the FE falls back to raw sections.
func (b *Builder) narasiAwam(ctx context.Context, ticker string, synth agents.Synthesis, sections []Section) string {
if b.Deps.LLM == nil || !b.Deps.LLM.Available() {
return ""
}
var sb strings.Builder
for _, s := range sections {
if strings.TrimSpace(s.Body) != "" {
sb.WriteString("- " + s.Name + ": " + s.Body + "\n")
}
}
text, err := b.Deps.LLM.Complete(ctx, b.Deps.SynthModel,
"Kamu asisten pasar saham Indonesia yang ramah. Dari data report berikut, tulis Bahasa Indonesia santai untuk orang awam dengan format:\n"+
"Baris 1: KESIMPULAN: <Dilirik/Tahan/Dilepas> — 1 kalimat.\n"+
"Baris 2-4: 3 alasan (masing-masing 1 kalimat, sebut angkanya).\n"+
"Baris 5: ARTINYA BUAT SAYA: 1 kalimat.\n"+
"Baris 6: RISIKO: 1 kalimat.\n"+
"Setiap angka WAJIB dari data — jangan mengarang. Maksimal 150 kata.",
"Ticker: "+ticker+"\nRekomendasi sistem: "+synth.Recommendation+
" (conviction "+fmt.Sprint(synth.Conviction)+"/5)\nTesis: "+synth.Thesis+
"\nData:\n"+head(sb.String(), 2500), 400)
if err != nil || strings.TrimSpace(text) == "" {
return ""
}
return strings.TrimSpace(text)
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation {
var out []model.Citation
for _, n := range names {
+123 -13
View File
@@ -11,6 +11,8 @@ import (
"fmt"
"log"
"net/url"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
@@ -175,6 +177,15 @@ func (s *Scheduler) RunCycle(ctx context.Context) error {
return err
}
}
// Full-universe rotation: every cycle pull depth (foreign flow included)
// for a slice of the remaining universe, credit-aware, so the dashboard
// chart picker eventually covers all ~800 IDX tickers, not just the 5.
if err := s.rotateDepth(ctx); err != nil {
log.Printf("scheduler: rotate: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.events(ctx); err != nil {
log.Printf("scheduler: events: %v", err)
}
@@ -190,12 +201,16 @@ func (s *Scheduler) watchlist() []string {
return s.watchlistFor(s.Cfg.DemoUserKey)
}
// watchlistFor resolves one owner's watchlist (demo seed fallback kept).
// watchlistFor resolves one owner's watchlist: personal first, then the
// "semua" default (every ticker with stored data), then the config list.
func (s *Scheduler) watchlistFor(owner string) []string {
wl, err := s.DB.Watchlist(owner)
if err == nil && len(wl) > 0 {
return wl
}
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
return all
}
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
return s.Cfg.Watchlist
}
@@ -230,33 +245,82 @@ func (s *Scheduler) reference(ctx context.Context) error {
return nil
}
// universe sweeps close/ pages for the latest trading day.
// universe sweeps close/ pages for the latest trading day and persists the
// full ticker list (all pages, not just the last) so AllTickers() knows the
// whole IDX universe. ~800 tickers = ~27 pages at 30/page; each page burns 1
// credit. Runs at most once per day (meta universe_last_sweep) and aborts on
// 429 keeping pages collected so far.
func (s *Scheduler) universe(ctx context.Context) error {
if s.DB.GetMeta("universe_last_sweep") == time.Now().Format("2006-01-02") {
return nil // already swept today; rotation fills depth incrementally
}
offset := 0
for page := 0; page < 12; page++ {
rows, total, err := s.Sectors.ClosePage(ctx, "", 30, offset)
var all []sectors.CloseRow
for page := 0; page < 40; page++ {
rows, _, err := s.Sectors.ClosePage(ctx, "", 30, offset)
if err != nil {
// Rate-limited mid-sweep: keep pages collected so far if any.
if len(all) > 0 {
break
}
return err
}
if raw, err := json.Marshal(rows); err == nil && len(rows) > 0 {
_ = s.DB.SaveSnapshot("IDX", rows[0].Date, "close", string(raw))
}
all = append(all, rows...)
offset += len(rows)
if offset >= total || len(rows) == 0 {
if len(rows) == 0 {
break
}
}
if len(all) == 0 {
return nil
}
s.DB.SetMeta("universe_last_sweep", time.Now().Format("2006-01-02"))
date := all[0].Date
for _, r := range all {
if r.Date > date {
date = r.Date
}
}
raw, _ := json.Marshal(all)
_ = s.DB.SaveSnapshot("IDX", date, "close", string(raw))
// Persist the universe ticker list so AllTickers() can return the full
// IDX set instead of only tickers that have depth data yet.
uni := make([]store.UniverseRow, 0, len(all))
for _, r := range all {
uni = append(uni, store.UniverseRow{Symbol: r.Symbol, Close: r.Close, Date: r.Date})
}
if err := s.DB.SaveUniverse(uni); err != nil {
log.Printf("scheduler: universe save: %v", err)
}
return nil
}
// market pulls top-changes (1 class x 2 periods), most-traded, idx-total,
// brokers/top — the cheap context block of the cycle.
// brokers/top — the cheap context block of the cycle. The top-changes call
// also enriches universe company names (MoverRow.Name) at no extra credit —
// n_stock is free; cost is classes×periods only.
func (s *Scheduler) market(ctx context.Context) error {
if g, l, err := s.Sectors.TopChanges(ctx,
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 5); err == nil {
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 750); err == nil {
if raw, err := json.Marshal(map[string]any{"top_gainers": g, "top_losers": l}); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "top-changes", string(raw))
}
// Capture company names from whatever mover rows came back.
names := map[string]string{}
collect := func(rows map[string][]sectors.MoverRow) {
for _, rs := range rows {
for _, r := range rs {
if r.Name != "" {
names[strings.TrimSuffix(r.Symbol, ".JK")] = r.Name
}
}
}
}
collect(g)
collect(l)
if err := s.DB.SetCompanyNames(names); err != nil {
log.Printf("scheduler: set company names: %v", err)
}
} else {
return err
}
@@ -279,9 +343,12 @@ func (s *Scheduler) market(ctx context.Context) error {
}
// tickerDepth pulls broker-summary/top + foreign-flow + daily per ticker.
// Dates use time.Now().UTC() — the Sectors API rejects end dates in the
// future relative to its UTC clock (WIB midnight > UTC previous day).
func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
end := time.Now().Format("2006-01-02")
start5 := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
now := time.Now().UTC()
end := now.Format("2006-01-02")
start5 := now.AddDate(0, 0, -6).Format("2006-01-02")
if top, err := s.Sectors.BrokerSummaryTop(ctx, ticker, start5, end, 10, "", ""); err == nil {
if raw, err := json.Marshal(top); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "broker-summary-top", string(raw))
@@ -298,7 +365,7 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
return err
}
if ff, err := s.Sectors.ForeignFlow(ctx, ticker,
time.Now().AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil {
now.AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(ff); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
@@ -315,6 +382,49 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
return nil
}
// rotateDepth incrementally deep-scans the whole universe across cycles:
// each cycle it pulls depth for a bounded slice of tickers that don't yet
// have a foreign-flow snapshot, respecting the credit cap. Progress is
// tracked via a meta cursor (universe_rotate_offset). Every ticker gets
// covered every ~80 cycles (10/cycle x 800), and the dashboard picker
// grows from 5 → ~800 tickers.
func (s *Scheduler) rotateDepth(ctx context.Context) error {
all, err := s.DB.AllTickers()
if err != nil || len(all) == 0 {
return nil
}
have, err := s.DB.TickersWithForeign()
if err != nil {
return err
}
missing := make([]string, 0, len(all))
for _, t := range all {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil // full coverage reached; daily freshness keeps it fresh
}
// Round-robin cursor so we don't always start at the same ticker.
start, _ := strconv.Atoi(s.DB.GetMeta("universe_rotate_offset"))
if start >= len(missing) {
start = 0
}
batch := 30 // 3 credits/ticker = 90 credits; universe sweep (27) runs once
// per day so a normal cycle stays within the 120 cap.
for i := 0; i < batch; i++ {
t := missing[(start+i)%len(missing)]
if err := s.tickerDepth(ctx, t); err != nil {
// 429 or transient: stop this cycle, resume next.
s.DB.SetMeta("universe_rotate_offset", fmt.Sprint((start+i)%len(missing)))
return err
}
}
s.DB.SetMeta("universe_rotate_offset", fmt.Sprint((start+batch)%len(missing)))
return nil
}
// events polls news/filings/suspensions incrementally via meta cursors.
func (s *Scheduler) events(ctx context.Context) error {
today := time.Now().Format("2006-01-02")
+79 -1
View File
@@ -5,10 +5,25 @@ import (
"encoding/hex"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
// the scoping key used by every user-owned table.
// the scoping key used by every user-owned table. Local accounts
// (username+password signup) store google_sub='local:<username>' with
// username+password_hash set, keeping session scoping unchanged.
type authErr string
func (e authErr) Error() string { return string(e) }
const (
ErrBadLogin authErr = "username atau password salah"
ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
ErrBadPassword authErr = "password minimal 8 karakter"
ErrTaken authErr = "username sudah dipakai"
)
type User struct {
ID int64
GoogleSub string
@@ -108,3 +123,66 @@ func (db *DB) DeleteSession(token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// localSub maps a username to its google_sub value for local accounts.
func localSub(username string) string { return "local:" + strings.ToLower(username) }
// CreateLocalUser registers a username+password account (bcrypt cost 10).
// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
func (db *DB) CreateLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !validUsername(username) {
return nil, ErrBadUsername
}
if len(password) < 8 {
return nil, ErrBadPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return nil, err
}
sub := localSub(username)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, ErrTaken
}
return nil, err
}
return db.UserBySub(sub)
}
// CheckLocalUser verifies username+password, returning the user on success.
func (db *DB) CheckLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
var u User
var hash string
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
FROM users WHERE username=?`, username).
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
return nil, ErrBadLogin
}
if hash == "" {
return nil, ErrBadLogin // Google-only account, no local password
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, ErrBadLogin
}
u.UserKey = UserKeyForSub(u.GoogleSub)
return &u, nil
}
// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
func validUsername(u string) bool {
if len(u) < 3 || len(u) > 32 {
return false
}
for _, c := range u {
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
continue
}
return false
}
return true
}
@@ -0,0 +1,4 @@
-- FlowSight schema v6: cached LLM narration for briefings.
-- narasi = 3 kalimat santai Indonesia hasil poles LLM (OmniRoute),
-- best-effort: kosong berarti FE pakai ringkasan rules.
ALTER TABLE briefings ADD COLUMN narasi TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,6 @@
-- FlowSight schema v7: local username+password login (Google stays optional).
-- Local accounts store google_sub='local:<username>' so session scoping
-- (sessions join + UserKeyForSub) works unchanged.
ALTER TABLE users ADD COLUMN username TEXT;
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
@@ -0,0 +1,16 @@
-- 0008_reports_owner.sql: reports now scoped to the owning user.
-- Existing rows are assigned to the shared demo key so historic reports
-- remain readable by the demo/seed pipeline; new reports carry user_key.
CREATE TABLE IF NOT EXISTS reports_new(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]',
user_key TEXT NOT NULL DEFAULT 'demo'
);
INSERT INTO reports_new(id, ticker, generated_at, payload_json, citations_json, user_key)
SELECT id, ticker, generated_at, payload_json, citations_json, 'demo' FROM reports;
DROP TABLE reports;
ALTER TABLE reports_new RENAME TO reports;
CREATE INDEX IF NOT EXISTS idx_reports_ticker ON reports(ticker, id);
-- SQLite ignores IF NOT EXISTS on column add; guard on table existence.
@@ -0,0 +1,6 @@
-- 0009_universe.sql: persisted full IDX ticker universe from the close sweep.
CREATE TABLE IF NOT EXISTS universe(
ticker TEXT PRIMARY KEY,
close INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,2 @@
-- 0010_universe_names.sql: company names for screener display.
ALTER TABLE universe ADD COLUMN company_name TEXT NOT NULL DEFAULT '';
+259 -25
View File
@@ -4,6 +4,8 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
@@ -400,6 +402,29 @@ func (db *DB) RunHistory(routineID int64, limit int, userKey ...string) ([]map[s
return out, rows.Err()
}
// LastRunsByRoutine returns the single latest run per routine for a user —
// used by ListRoutines to avoid an N+1 query per routine row.
func (db *DB) LastRunsByRoutine(userKey string) (map[int64]map[string]any, error) {
rows, err := q(db, `SELECT routine_id, started_at, status
FROM routine_runs WHERE user_key=?
AND id IN (SELECT MAX(id) FROM routine_runs WHERE user_key=? GROUP BY routine_id)`,
userKey, userKey)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]map[string]any{}
for rows.Next() {
var rid int64
var started, status string
if err := rows.Scan(&rid, &started, &status); err != nil {
return nil, err
}
out[rid] = map[string]any{"started_at": started, "status": status}
}
return out, rows.Err()
}
// Alerts.
// Alert is one user rule row.
@@ -540,6 +565,178 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
return out, rows.Err()
}
// AllTickers returns every ticker that has snapshot data, excluding pseudo
// tickers (IDX market-wide rows, ROE registry rows). This is the "semua"
// default universe: dashboard, screener fallback, and scheduler depth all use
// it when a user has no personal watchlist.
func (db *DB) AllTickers() ([]string, error) {
// Prefer the persisted full-universe list (from close/ sweep).
rows, err := db.Query(`SELECT ticker FROM universe ORDER BY ticker`)
if err == nil {
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
rows.Close()
break
}
out = append(out, t)
}
rows.Close()
if len(out) > 0 {
return out, nil
}
}
// Fallback: distinct snapshots (avoids pseudo indices).
rows2, err := db.Query(`SELECT DISTINCT ticker FROM snapshots
WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
if err != nil {
return nil, err
}
defer rows2.Close()
var out2 []string
for rows2.Next() {
var t string
if err := rows2.Scan(&t); err != nil {
return nil, err
}
out2 = append(out2, t)
}
return out2, rows2.Err()
}
// TickersWithForeign returns the set of tickers that have ≥1 foreign-flow row.
func (db *DB) TickersWithForeign() (map[string]bool, error) {
rows, err := db.Query(`SELECT DISTINCT ticker FROM foreign_flow`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out[t] = true
}
return out, rows.Err()
}
// SaveUniverse inserts the full ticker universe from the close sweep.
// UniverseRow mirrors the CloseRow shape without importing sectors (cycle).
type UniverseRow struct {
Symbol string
Close int64
Date string
CompanyName string
}
func (db *DB) SaveUniverse(rows []UniverseRow) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM universe`); err != nil {
return err
}
for _, r := range rows {
// Normalize: API returns "BBCA.JK" — strip the suffix so it matches
// watchlist/depth keys (BBCA) everywhere.
t := strings.TrimSuffix(r.Symbol, ".JK")
if _, err := tx.Exec(`INSERT OR IGNORE INTO universe(ticker, close, date, company_name) VALUES(?,?,?,?)`,
t, r.Close, r.Date, r.CompanyName); err != nil {
return err
}
}
return tx.Commit()
}
// CompanyNames returns the company name map for the given tickers (from the
// universe table). Tickers with no stored name are omitted.
func (db *DB) CompanyNames(tickers []string) (map[string]string, error) {
if len(tickers) == 0 {
return map[string]string{}, nil
}
ph := strings.Repeat("?,", len(tickers))
ph = ph[:len(ph)-1]
args := make([]any, len(tickers))
for i, t := range tickers {
args[i] = t
}
rows, err := db.Query(`SELECT ticker, company_name FROM universe
WHERE company_name != '' AND ticker IN (`+ph+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var t, nm string
if err := rows.Scan(&t, &nm); err != nil {
return nil, err
}
out[t] = nm
}
return out, rows.Err()
}
// SetCompanyNames bulk-upserts company_name for tickers (from top-changes
// snapshots). Only non-empty names are written; existing names win (skip
// blank overwrite) so a later richer source doesn't clobber a good one.
func (db *DB) SetCompanyNames(names map[string]string) error {
if len(names) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for t, nm := range names {
nm = strings.TrimSpace(nm)
if nm == "" || strings.EqualFold(nm, t) {
continue
}
if _, err := tx.Exec(`UPDATE universe SET company_name = ? WHERE ticker = ? AND company_name = ''`, nm, t); err != nil {
return err
}
}
return tx.Commit()
}
// TickerHit is one local search result over the universe table.
type TickerHit struct {
Symbol string
CompanyName string
}
// SearchTickers finds universe tickers matching q in ticker or company_name
// (case-insensitive), most-traded first. Works offline — no Sectors API call.
func (db *DB) SearchTickers(q string, limit int) ([]TickerHit, error) {
if limit <= 0 {
limit = 20
}
like := "%" + strings.ToUpper(strings.TrimSpace(q)) + "%"
rows, err := db.Query(`SELECT ticker, company_name FROM universe
WHERE ticker LIKE ? OR UPPER(company_name) LIKE ?
ORDER BY close DESC LIMIT ?`, like, like, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TickerHit
for rows.Next() {
var h TickerHit
if err := rows.Scan(&h.Symbol, &h.CompanyName); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// AddWatch inserts a ticker (idempotent).
func (db *DB) AddWatch(userKey, ticker string) error {
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
@@ -589,10 +786,10 @@ func (db *DB) FilingsSince(ticker, since string, limit int) ([]map[string]any, e
// Reports reports / briefings.
// SaveReport stores a generated report; returns its id.
func (db *DB) SaveReport(ticker, payload, cites string) (int64, error) {
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json)
VALUES(?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites)
// SaveReport stores a generated report owned by userKey; returns its id.
func (db *DB) SaveReport(ticker, payload, cites, userKey string) (int64, error) {
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json,user_key)
VALUES(?,?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites, userKey)
if err != nil {
return 0, err
}
@@ -622,7 +819,16 @@ func (db *DB) ListReports(ticker string, limit int) ([]map[string]any, error) {
return out, rows.Err()
}
// LatestReport returns the newest report payload for a ticker.
// LatestReportForUser returns the newest report payload for a ticker scoped
// to the calling user (IDOR fix: never leak other users' reports).
func (db *DB) LatestReportForUser(ticker, userKey string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`, ticker, userKey).Scan(&payload, &cites, &at)
return payload, cites, at, err
}
// LatestReport returns the newest report payload for a ticker (global —
// used only where user scoping is not required, e.g. internal reads).
func (db *DB) LatestReport(ticker string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? ORDER BY id DESC LIMIT 1`, ticker).Scan(&payload, &cites, &at)
@@ -637,11 +843,19 @@ func (db *DB) SaveBriefing(date, payload, cites string) error {
return err
}
// LatestBriefing returns the newest briefing.
func (db *DB) LatestBriefing() (date, payload, cites string, err error) {
err = db.QueryRow(`SELECT date, payload_json, citations_json FROM briefings
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites)
return date, payload, cites, err
// SaveNarasi caches the LLM-polished narration for a briefing date
// (best-effort; empty string clears it).
func (db *DB) SaveNarasi(date, narasi string) error {
_, err := db.Exec(`UPDATE briefings SET narasi=? WHERE date=?`, narasi, date)
return err
}
// LatestBriefing returns the newest briefing (plus cached narration).
func (db *DB) LatestBriefing() (date, payload, cites, narasi string, err error) {
err = db.QueryRow(`SELECT date, payload_json, citations_json,
COALESCE(narasi,'') FROM briefings
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites, &narasi)
return date, payload, cites, narasi, err
}
// Accuracy ledger.
@@ -756,28 +970,48 @@ func (db *DB) PruneSnapshots(cutoff string) (int64, error) {
// Daily volumes for technical/volume rules.
// DailyVolumes returns date-ordered volumes for a ticker from snapshots.
// DailyVolumes returns date-ordered volumes for a ticker from snapshots,
// deduped by bar date (each snapshot row stores a multi-day window, so a
// naive LIMIT over rows massively duplicates dates and skews volume-anomaly
// math). Returns the newest `limit` distinct trading days, oldest-first.
func (db *DB) DailyVolumes(ticker string, limit int) ([]float64, []string, error) {
// Pull extra rows (each holds up to 30 bars) and dedupe by bar date.
rows, err := db.Query(`SELECT date, payload_json FROM snapshots
WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, limit)
WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, 200)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var vols []float64
var dates []string
byDate := map[string]float64{}
seen := map[string]bool{}
var dateOrder []string
for rows.Next() {
var d, p string
if err := rows.Scan(&d, &p); err != nil {
return nil, nil, err
}
for _, b := range parseDailyBars(p, d) {
if b.Volume > 0 {
vols, dates = append(vols, b.Volume), append(dates, b.Date)
if b.Volume <= 0 || seen[b.Date] {
continue
}
seen[b.Date] = true
byDate[b.Date] = b.Volume
dateOrder = append(dateOrder, b.Date)
}
}
return vols, dates, rows.Err()
if err := rows.Err(); err != nil {
return nil, nil, err
}
// dateOrder is newest-first from the DESC scan; reverse for ascending.
sort.Strings(dateOrder)
if len(dateOrder) > limit {
dateOrder = dateOrder[len(dateOrder)-limit:]
}
vols := make([]float64, 0, len(dateOrder))
for _, d := range dateOrder {
vols = append(vols, byDate[d])
}
return vols, dateOrder, nil
}
// LatestClose returns the most recent close for a ticker.
@@ -959,17 +1193,17 @@ func (db *DB) UpdateDestination(id int64, userKey string, label *string, enabled
// GetDestination returns one push target owned by the user, or nil.
func (db *DB) GetDestination(id int64, userKey string) (*Destination, error) {
all, err := db.ListDestinations(userKey)
var d Destination
err := db.QueryRow(`SELECT id, user_key, kind, label, bot_token, chat_id, webhook_url, enabled
FROM notification_destinations WHERE id=? AND user_key=?`, id, userKey).
Scan(&d.ID, &d.UserKey, &d.Kind, &d.Label, &d.BotToken, &d.ChatID, &d.WebhookURL, &d.Enabled)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
for _, d := range all {
if d.ID == id {
c := d
return &c, nil
}
}
return nil, nil
return &d, nil
}
// DeleteDestination removes one push target owned by the user.
+93 -1
View File
@@ -50,7 +50,7 @@ func TestStoreParity(t *testing.T) {
if err != nil || len(fils) != 1 {
t.Fatalf("filings = %v, %v", fils, err)
}
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`, "tester")
reps, err := db.ListReports("BBCA", 10)
if err != nil || len(reps) != 1 {
t.Fatalf("reports = %v, %v", reps, err)
@@ -107,3 +107,95 @@ func TestDestinationCRUD(t *testing.T) {
t.Fatal("double delete must report false")
}
}
// Universe save + local ticker search + company-name lookup.
func TestUniverseSearch(t *testing.T) {
db, err := Open(t.TempDir() + "/univ.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
uni := []UniverseRow{
{Symbol: "BBCA.JK", Close: 100, Date: "2026-09-16", CompanyName: "PT Bank Central Asia Tbk"},
{Symbol: "TLKM.JK", Close: 90, Date: "2026-09-16"},
{Symbol: "BBRI.JK", Close: 80, Date: "2026-09-16", CompanyName: "PT Bank Rakyat Indonesia"},
}
if err := db.SaveUniverse(uni); err != nil {
t.Fatal(err)
}
// Search by ticker prefix (case-insensitive).
hits, err := db.SearchTickers("bca", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Symbol != "BBCA" {
t.Fatalf("bca hits = %+v, want BBCA", hits)
}
// Search by company name.
hits, err = db.SearchTickers("bank", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) != 2 {
t.Fatalf("bank hits = %d, want 2 (BBCA+BBRI by name)", len(hits))
}
// .JK stripped on save, order by close desc.
if hits[0].Symbol != "BBCA" || hits[1].Symbol != "BBRI" {
t.Fatalf("bank order = %s,%s, want BBCA,BBRI", hits[0].Symbol, hits[1].Symbol)
}
// CompanyNames returns only tickers with stored names.
names, err := db.CompanyNames([]string{"BBCA", "TLKM", "BBRI"})
if err != nil {
t.Fatal(err)
}
if names["BBCA"] != "PT Bank Central Asia Tbk" || names["BBRI"] != "PT Bank Rakyat Indonesia" {
t.Fatalf("names = %+v, want BBCA+BBRI only", names)
}
if _, ok := names["TLKM"]; ok {
t.Fatal("TLKM has no name, must be omitted")
}
}
// SetCompanyNames fills blank names; never overwrites existing ones; skips
// blanks and ticker-equal names.
func TestSetCompanyNames(t *testing.T) {
db, err := Open(t.TempDir() + "/names.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
uni := []UniverseRow{
{Symbol: "BBCA.JK", Close: 100, Date: "2026-09-16", CompanyName: "PT Bank Central Asia Tbk"},
{Symbol: "TLKM.JK", Close: 90, Date: "2026-09-16"},
{Symbol: "BBRI.JK", Close: 80, Date: "2026-09-16"},
}
if err := db.SaveUniverse(uni); err != nil {
t.Fatal(err)
}
// Fill TLKM + BBRI; BBCA already has a name (must NOT be clobbered);
// "UNVR" would be a new row (INSERT OR IGNORE means it's skipped).
err = db.SetCompanyNames(map[string]string{
"TLKM": "PT Telkom Indonesia (Persero) Tbk",
"BBRI": "PT Bank Rakyat Indonesia (Persero) Tbk",
"BBCA": "SHOULD NOT OVERWRITE",
"UNVR": "PT Unilever Indonesia Tbk",
"EMTK": "PT Elang Mahkota Teknologi Tbk", // no universe row -> no-op
"BLAH": "BLAH", // ticker-equal -> skip
})
if err != nil {
t.Fatal(err)
}
names, err := db.CompanyNames([]string{"BBCA", "TLKM", "BBRI", "UNVR"})
if err != nil {
t.Fatal(err)
}
if names["BBCA"] != "PT Bank Central Asia Tbk" {
t.Fatalf("BBCA clobbered: %q", names["BBCA"])
}
if names["TLKM"] != "PT Telkom Indonesia (Persero) Tbk" || names["BBRI"] != "PT Bank Rakyat Indonesia (Persero) Tbk" {
t.Fatalf("names = %+v, want TLKM+BBRI filled", names)
}
if _, ok := names["UNVR"]; ok {
t.Fatal("UNVR not in universe must be absent")
}
}
+57
View File
@@ -0,0 +1,57 @@
import { For, Show } from "solid-js";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ISTILAH, type Verdict } from "../lib/awam";
// Kata teknis bergaris-titik + tooltip penjelasan santai.
export function Term(props: { kata: string }) {
const key = props.kata.toLowerCase();
const arti = ISTILAH[key];
if (!arti) return <span>{props.kata}</span>;
return (
<Tooltip>
<TooltipTrigger as="span" class="cursor-help underline decoration-dotted underline-offset-2">{props.kata}</TooltipTrigger>
<TooltipContent class="max-w-xs">{arti}</TooltipContent>
</Tooltip>
);
}
// Verdict selalu hijau (Dilirik) / merah (Dilepas) / abu (Netral) —
// arti warna konsisten di seluruh app: hijau = kabar baik, merah = waspada.
export function VerdictBadge(props: { verdict: Verdict }) {
const warna: Record<Verdict, "success" | "error" | "secondary"> = {
"Dilirik": "success",
"Dilepas": "error",
"Netral": "secondary",
};
return <Badge variant={warna[props.verdict]}>{props.verdict}</Badge>;
}
// Bar alasan: porsi bukti asing (biru, netral-informatif) vs broker (ungu).
// BUKAN hijau/merah — bar ini soal "bukti dari mana", bukan "baik/buruk".
export function AlasanBar(props: { asing: number; broker: number }) {
const total = Math.abs(props.asing) + Math.abs(props.broker) || 1;
const pAsing = Math.round((Math.abs(props.asing) / total) * 100);
return (
<div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
<div class="bg-sky-500" style={{ width: `${pAsing}%` }} />
<div class="bg-violet-500" style={{ width: `${100 - pAsing}%` }} />
</div>
<div class="mt-1 flex justify-between text-xs text-muted-foreground">
<span><span class="text-sky-500">■</span> Bukti asing {props.asing >= 0 ? "masuk" : "keluar"}</span>
<span><span class="text-violet-500">■</span> Bukti broker {props.broker >= 0 ? "borong" : "jualan"}</span>
</div>
</div>
);
}
export function IstilahStrip() {
const kunci = ["akumulasi", "foreign flow", "broker", "net", "briefing", "alert"];
return (
<p class="text-xs text-muted-foreground">
Istilah asing? Arahkan kursor:{" "}
<For each={kunci}>{(k, i) => <><Show when={i() > 0}>, </Show><Term kata={k} /></>}</For>
</p>
);
}
+28 -13
View File
@@ -11,27 +11,34 @@ export function WatchlistDrawer() {
const [err, setErr] = createSignal("");
async function add() {
setErr("");
const t = ticker().toUpperCase().trim();
if (!t) return;
try {
await api.addWatch(ticker().toUpperCase().trim());
await api.addWatch(t);
setTicker("");
refetch();
} catch (e) { setErr(String(e)); }
}
async function del(t: string) {
await api.removeWatch(t);
refetch();
try {
await api.removeWatch(t);
refetch();
} catch (e) { setErr(String(e)); }
}
return (
<Card>
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
<CardContent class="space-y-2">
<div class="flex gap-2">
<TextField class="w-24"><TextFieldInput placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
<label class="sr-only" for="wl-ticker">Tambah ticker</label>
<TextField class="w-24"><TextFieldInput id="wl-ticker" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
<Button size="sm" onClick={add}>Add</Button>
</div>
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
<ul class="flex flex-wrap gap-1.5">
<For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" onClick={() => del(t)}>×</button></li>}</For>
<Show when={(wl()?.watchlist || []).length} fallback={<li class="text-xs text-muted-foreground">Ketik kode saham lalu Add — mis. BBCA, TLKM, BBRI.</li>}>
<For each={wl()?.watchlist || []}>{(t) => <li class="flex items-center gap-1"><a href={`/report/${encodeURIComponent(t)}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" aria-label={`Hapus ${t} dari watchlist`} onClick={() => del(t)}>×</button></li>}</For>
</Show>
</ul>
</CardContent>
</Card>
@@ -40,7 +47,7 @@ export function WatchlistDrawer() {
export function ChatSidebar() {
const [msg, setMsg] = createSignal("");
const [log, setLog] = createSignal<{ q: string; a: string }[]>([]);
const [log, setLog] = createSignal<{ q: string; a: string; err?: boolean }[]>([]);
async function send() {
const q = msg().trim();
if (!q) return;
@@ -49,20 +56,28 @@ export function ChatSidebar() {
const r = await api.chat(q);
setLog((p) => [...p, { q, a: r.answer }].slice(-10));
} catch (e) {
setLog((p) => [...p, { q, a: String(e) }].slice(-10));
setLog((p) => [...p, { q, a: String(e), err: true }].slice(-10));
}
}
return (
<Card>
<CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader>
<CardContent class="space-y-2">
<div class="max-h-64 space-y-2 overflow-y-auto">
<For each={log()}>
{(m) => <div class="text-sm"><p><strong>You:</strong> {m.q}</p><p class="text-muted-foreground">{m.a.slice(0, 500)}</p></div>}
</For>
</div>
<Show when={log().length} fallback={<p class="text-xs text-muted-foreground">Tanya soal watchlist-mu — sistem jawab dari data yang tersimpan (bukan karangan).</p>}>
<div class="max-h-64 space-y-2 overflow-y-auto">
<For each={log()}>
{(m) => (
<div class="text-sm">
<p><strong>You:</strong> {m.q}</p>
<p classList={{ "text-muted-foreground": !m.err, "text-destructive": !!m.err }}>{m.a.slice(0, 500)}</p>
</div>
)}
</For>
</div>
</Show>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
<label class="sr-only" for="chat-msg">Tanya AI</label>
<TextField class="flex-1"><TextFieldInput id="chat-msg" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
<Button size="sm" onClick={send}>Send</Button>
</div>
</CardContent>
+27 -4
View File
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
// Theme: .dark class on <html> (shadcn convention). Default dark.
function theme(): string {
@@ -27,17 +28,39 @@ export function ThemeToggle() {
}
export function useAuth() {
// me() is undefined while loading, null when logged out, AuthUser when in.
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
try { return (await api.me()).user; } catch { return null; }
});
return { me, refetch };
const [version] = createResource(async () => {
try { return await api.version(); } catch { return null; }
});
return { me, refetch, version };
}
// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout.
export function ButuhLogin(props: { fitur: string }) {
return (
<div class="mx-auto max-w-md py-[6vh]">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-2xl">🔒 {props.fitur} perlu login</CardTitle>
<CardDescription>
Biar datanya milik kamu sendiri (watchlist, notifikasi, report) —
daftar gratis, cukup username + password.
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<a href="/login"><Button size="lg">Masuk / Daftar</Button></a>
</CardContent>
</Card>
</div>
);
}
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
return (
<Show when={props.me} fallback={
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
}>
<Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
{(u) => (
<span class="flex items-center gap-2">
<Avatar class="size-8">
+120 -37
View File
@@ -1,12 +1,12 @@
import { render } from "solid-js/web";
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { createResource, createSignal, createEffect, Show } from "solid-js";
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
import { Button } from "./components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
import { TextField, TextFieldInput } from "./components/ui/text-field";
import { api } from "./lib/api";
import { api, AUTH_EXPIRED_EVENT } from "./lib/api";
import Dashboard from "./pages/Dashboard";
import Routines from "./pages/Routines";
import Screener from "./pages/Screener";
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
import Portfolio from "./pages/Portfolio";
import "./styles/globals.css";
const NAV = [
const NAV_ALL = [
{ href: "/", icon: "📊", label: "Dashboard" },
{ href: "/routines", icon: "🗓️", label: "Routines" },
{ href: "/screener", icon: "🔍", label: "Screener" },
@@ -23,30 +23,40 @@ const NAV = [
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
];
function Nav() {
function Nav(props: { loggedIn: boolean }) {
const loc = useLocation();
// Belum login: hanya Dashboard yg terlihat (fitur lain di-hide).
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
const isActive = (href: string) => loc.pathname === href || (href !== "/" && loc.pathname.startsWith(href));
return (
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
<div class="mb-4 px-2 text-xl font-bold tracking-tight">
Flow<span class="text-primary">Sight</span>
<div class="mb-5 px-2">
<a href="/" class="flex items-center gap-2 text-xl font-bold tracking-tight">
<span class="grid size-7 place-items-center rounded-lg bg-primary text-sm text-primary-foreground">📈</span>
Flow<span class="text-primary">Sight</span>
</a>
</div>
{NAV.map((n) => (
{items().map((n) => (
<a
href={n.href}
class={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${loc.pathname === n.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
class={`relative flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${isActive(n.href) ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
>
<span>{n.icon}</span> {n.label}
<span class="text-base leading-none">{n.icon}</span> {n.label}
<Show when={isActive(n.href)}>
<span class="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
</Show>
</a>
))}
</nav>
);
}
function MobileNav() {
function MobileNav(props: { loggedIn: boolean }) {
const loc = useLocation();
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
return (
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
{NAV.map((n) => (
{items().map((n) => (
<a
href={n.href}
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
@@ -58,38 +68,82 @@ function MobileNav() {
);
}
function TickerSearch() {
function TickerSearch(props: { loggedIn: boolean }) {
const nav = useNavigate();
const [q, setQ] = createSignal("");
const go = () => {
const t = q().toUpperCase().trim();
if (t) nav(`/report/${t}`);
const go = (t: string) => {
const v = t.toUpperCase().trim();
// Report butuh login: arahkan yg belum login ke /login.
if (v) nav(props.loggedIn ? `/report/${v}` : "/login");
};
return (
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
<TextField class="flex-1">
<TextFieldInput placeholder="Ticker → report (e.g. BBCA)" value={q()}
onInput={(e) => setQ(e.currentTarget.value)} />
</TextField>
<Button type="submit" variant="secondary">Open</Button>
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(q()); }}>
<div class="relative flex-1">
<TextFieldInput
placeholder="Cari kode saham…"
value={q()}
aria-label="Cari kode saham"
onInput={(e) => setQ(e.currentTarget.value)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); go(q()); } }}
class="h-9 pr-8"
/>
<span class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">🔍</span>
</div>
<Button type="submit" variant="secondary" class="h-9 px-3">Buka</Button>
</form>
);
}
function LoginPage() {
const params = useParams<{ error?: string }>();
const err = () => new URLSearchParams(window.location.search).get("error");
void params;
const [mode, setMode] = createSignal<"masuk" | "daftar">("masuk");
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [formErr, setFormErr] = createSignal("");
const [version] = createResource(() => api.version().catch(() => null));
async function submit(e: Event) {
e.preventDefault();
setFormErr(""); setBusy(true);
try {
if (mode() === "daftar") await api.signup(username().trim(), password());
else await api.login(username().trim(), password());
window.location.href = "/";
} catch (e2) { setFormErr(String(e2)); }
finally { setBusy(false); }
}
return (
<div class="mx-auto max-w-md py-[8vh]">
<div class="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-[6vh]">
<div class="mb-6 flex items-center justify-center gap-2 text-2xl font-bold tracking-tight">
<span class="grid size-9 place-items-center rounded-xl bg-primary text-lg text-primary-foreground">📈</span>
Flow<span class="text-primary">Sight</span>
</div>
<Card>
<CardHeader class="text-center">
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle>
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription>
<CardTitle class="text-2xl">Masuk ke FlowSight 👋</CardTitle>
<CardDescription>
Daftar gratis pakai username + password — watchlist, notifikasi,
dan report jadi milik kamu sendiri.
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show>
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a>
<CardContent class="space-y-3">
<Show when={err()}><p class="text-sm text-destructive">Login gagal: {err()}</p></Show>
<div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={mode() === "masuk" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("masuk")}>Masuk</Button>
<Button size="sm" variant={mode() === "daftar" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("daftar")}>Daftar baru</Button>
</div>
<form class="space-y-3" onSubmit={submit}>
<TextField><TextFieldInput placeholder="username (mis. budi)" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} autocomplete="username" /></TextField>
<TextField><TextFieldInput type="password" placeholder="password (min. 8 karakter)" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} autocomplete={mode() === "daftar" ? "new-password" : "current-password"} /></TextField>
<Show when={formErr()}><p class="text-sm text-destructive">{formErr()}</p></Show>
<Button type="submit" size="lg" class="w-full" disabled={busy()}>{busy() ? "…" : mode() === "daftar" ? "Daftar & Masuk" : "Masuk"}</Button>
</form>
<Show when={version()?.google_configured}>
<div class="pt-1 text-center">
<p class="mb-2 text-xs text-muted-foreground">atau</p>
<a href="/api/auth/start"><Button size="lg" variant="outline" class="w-full">Sign in with Google</Button></a>
</div>
</Show>
</CardContent>
</Card>
</div>
@@ -98,13 +152,24 @@ function LoginPage() {
function Shell(props: RouteSectionProps) {
const { me, refetch } = useAuth();
const logout = async () => { await api.logout(); refetch(); };
const nav = useNavigate();
// Any 401 on a gated API while logged in → session expired; go to login.
createEffect(() => {
const h = () => { if (me()) { refetch(); nav("/login"); } };
window.addEventListener(AUTH_EXPIRED_EVENT, h);
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, h);
});
const logout = async () => {
try { await api.logout(); } catch { /* server gone — clear locally anyway */ }
refetch();
nav("/");
};
return (
<div class="flex min-h-screen bg-background text-foreground">
<Nav />
<Nav loggedIn={!!me()} />
<div class="flex min-w-0 flex-1 flex-col">
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
<TickerSearch />
<TickerSearch loggedIn={!!me()} />
<span class="flex-1" />
<ThemeToggle />
<AuthButton me={me()} onLogout={logout} />
@@ -112,14 +177,32 @@ function Shell(props: RouteSectionProps) {
<main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main>
</div>
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
<WatchlistDrawer />
<ChatSidebar />
<Show when={me()} fallback={
<Card><CardHeader class="pb-2"><CardTitle class="text-base">🔒 Login dulu</CardTitle></CardHeader>
<CardContent class="space-y-2"><p class="text-sm text-muted-foreground">Watchlist, filter, dan AI chat milikmu sendiri setelah login.</p>
<a href="/login"><Button size="sm" class="w-full">Masuk / Daftar</Button></a></CardContent></Card>
}>
<WatchlistDrawer />
<ChatSidebar />
</Show>
</aside>
<MobileNav />
<MobileNav loggedIn={!!me()} />
</div>
);
}
// Gate: bungkus halaman fitur — belum login tampilkan ButuhLogin.
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
const { me } = useAuth();
return (
<Show when={me() !== undefined} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
{props.children}
</Show>
</Show>
);
}
render(
() => (
<Router root={Shell}>
+23 -10
View File
@@ -1,11 +1,19 @@
// Typed backend client (backend is the only source of truth; web never calls Sectors directly).
const BASE = "";
function headers(): HeadersInit {
return { "Content-Type": "application/json", "X-User-Key": localStorage.getItem("fs-key") || "demo" };
// fs-key is legacy demo identity; cookie auth wins server-side. Keep sending
// session cookie implicitly (same-origin default) — no token in localStorage.
return { "Content-Type": "application/json" };
}
// auth-expired: global signal → Shell refetches /me and redirects to /login.
export const AUTH_EXPIRED_EVENT = "fs:auth-expired";
function notifyAuthExpired() {
try { window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT)); } catch { /* ignore */ }
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
if (!r.ok) {
if (r.status === 401 && !path.startsWith("/api/auth/")) notifyAuthExpired();
let msg = r.statusText;
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
throw new Error(msg);
@@ -17,23 +25,25 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
}
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; citations: Citation[] }
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[]; foreign_tickers?: string[] }
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[] }
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
export interface ForeignSeries { dates: string[]; nets: number[]; reversal: boolean; start?: string; end?: string; citations: Citation[] }
export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
flowForeign: (ticker: string) => req<{ dates: string[]; nets: number[]; reversal: boolean; citations: Citation[] }>(`/api/flow/foreign?ticker=${ticker}`),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${encodeURIComponent(ticker)}`),
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${encodeURIComponent(ticker)}`),
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"),
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteRoutine: (id: number) => req<unknown>(`/api/routines/${id}`, { method: "DELETE" }),
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
briefing: () => req<{ date: string; payload: string; citations: string }>("/api/briefing/today"),
briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
createDestination: (body: Record<string, unknown>) => req<{ id: number }>("/api/destinations", { method: "POST", body: JSON.stringify(body) }),
updateDestination: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/destinations/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
@@ -42,17 +52,20 @@ export const api = {
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${ticker}?profile=${profile}`, { method: "POST" }),
reportMd: (ticker: string) => req<string>(`/api/report/${ticker}?format=md`, { method: "POST" }),
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${ticker}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${encodeURIComponent(ticker)}?profile=${profile}`, { method: "POST" }),
reportMd: (ticker: string) => req<string>(`/api/report/${encodeURIComponent(ticker)}?format=md`, { method: "POST" }),
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${encodeURIComponent(ticker)}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${encodeURIComponent(ticker)}`, { method: "DELETE" }),
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
signup: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/signup`, { method: "POST", body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/login`, { method: "POST", body: JSON.stringify({ username, password }) }),
version: () => req<{ commit: string; started_at: string; google_configured: boolean }>(`/api/version`),
};
// SSE hook helper: subscribe to channels agents|alerts|activity.
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
+117
View File
@@ -0,0 +1,117 @@
import type { ScreenRow, FlowSummary, ForeignSeries } from "./api";
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
export const ISTILAH: Record<string, string> = {
"akumulasi": "Broker-broker besar lagi borong saham ini diam-diam. Biasanya tanda mereka tahu sesuatu yang bagus.",
"distribusi": "Broker-broker besar lagi jualan saham ini pelan-pelan. Hati-hati, bisa turun.",
"foreign flow": "Uang investor luar negeri yang masuk (beli) atau keluar (jual) dari saham ini.",
"inflow": "Uang asing lagi masuk — mereka beli. Tanda kepercayaan.",
"outflow": "Uang asing lagi keluar — mereka jual. Waspada.",
"broker": "Perantara jual-beli saham (sekuritas). Gerakan mereka dibaca buat nebak arah pasar.",
"net": "Selisih total beli dikurangi total jual. Positif = lebih banyak yang beli.",
"composite": "Nilai gabungan dari semua sinyal (asing + broker + volume). Makin tinggi makin menarik.",
"screener": "Alat penyaring: cari saham yang memenuhi kriteria tertentu.",
"briefing": "Ringkasan pagi otomatis: kabar pasar hari ini dalam beberapa kalimat.",
"alert": "Notifikasi otomatis kalau saham incaranmu bergerak aneh.",
"routine": "Jadwal otomatis, misalnya terima ringkasan pasar tiap pagi jam 7.",
"portofolio": "Kumpulan semua saham yang kamu pegang.",
"diversifikasi": "Jangan taruh semua uang di satu saham — sebar biar aman.",
"rekomendasi": "Saran beli/tahan/jual dari sistem. Bukan nasihat keuangan resmi — tetap riset sendiri.",
"hold": "Tahan: jangan jual dulu, tapi belum saatnya tambah beli.",
"buy": "Beli: sinyalnya bagus, layak dipertimbangkan.",
"sell": "Jual: sinyalnya jelek, pertimbangkan keluar.",
"citations": "Sumber data: dari API mana angka ini diambil, biar bisa diverifikasi.",
"live": "Data real-time dari scheduler, bukan contoh.",
"stale": "Data sudah lama, belum diperbarui. Jangan terlalu dipercaya.",
"mover": "Saham yang harganya bergerak drastis hari ini.",
"agenda": "Jadwal penting emiten: bagi dividen, rapat pemegang saham, laporan keuangan.",
"ex-div": "Tanggal batas: kalau beli setelah tanggal ini, tidak dapat dividen periode ini.",
};
export type Verdict = "Dilirik" | "Dilepas" | "Netral";
export function verdictFor(row: ScreenRow): { verdict: Verdict; alasan: string } {
const b = (row.breakdown || {}) as Record<string, unknown>;
const comp = Number(row.composite ?? 0);
const trend = String(b.foreign_trend ?? "");
const broker = Number(b.broker_score ?? b.broker ?? 0);
const insider = Boolean(b.insider_buying ?? b.insider);
if (comp > 5 || (trend === "inflow" && broker > 0)) {
const bits: string[] = [];
if (trend === "inflow") bits.push("uang asing masuk");
if (broker > 0) bits.push("broker besar borong");
if (insider) bits.push("orang dalam ikut beli");
return { verdict: "Dilirik", alasan: bits.length ? bits.join(", ") : "sinyal gabungan positif" };
}
if (comp < -5 || (trend === "outflow" && broker < 0)) {
const bits: string[] = [];
if (trend === "outflow") bits.push("uang asing keluar");
if (broker < 0) bits.push("broker besar jualan");
return { verdict: "Dilepas", alasan: bits.length ? bits.join(", ") : "sinyal gabungan negatif" };
}
return { verdict: "Netral", alasan: "belum ada gerakan mencolok — pantau saja" };
}
// Ringkas briefing mentah jadi 3 kalimat santai.
export function heroSummary(briefing: string): string[] {
const lines = briefing.split("\n").filter((l) => l.trim());
const top = lines.filter((l) => /^\d+\./.test(l.trim())).slice(0, 3);
const foreign = lines.filter((l) => /^-/.test(l.trim()) && /Rp/i.test(l)).slice(0, 2);
const mover = lines.find((l) => /mover/i.test(l));
const out: string[] = [];
if (top.length) {
const names = top.map((t) => t.replace(/^\d+\.\s*/, "").split(" net")[0]).join(", ");
out.push(`Pagi ini yang paling diborong: ${names}. Artinya pemain besar lagi kumpul di sana.`);
}
if (foreign.length) out.push(`Uang asing: ${foreign.map((f) => f.replace(/^-\s*/, "")).join("; ")}.`);
if (mover) out.push(`Perhatian khusus: ${mover.replace(/^.*?:\s*/, "")}.`);
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
return out.slice(0, 3);
}
// fmtRp: 1.78T / 305B / 25M — untuk harga pakai fmtHarga.
export function fmtRp(v: number): string {
const neg = v < 0; const a = Math.abs(v);
let s: string;
if (a >= 1e12) s = `Rp${(a / 1e12).toFixed(2)}T`;
else if (a >= 1e9) s = `Rp${Math.round(a / 1e9)}B`;
else if (a >= 1e6) s = `Rp${Math.round(a / 1e6)}M`;
else s = `Rp${Math.round(a)}`;
return neg ? "-" + s : s;
}
export function fmtHarga(v: number): string {
return `Rp${Math.round(v).toLocaleString("id-ID")}`;
}
// Cerita 1 kalimat per kartu sorotan: harga + arah asing + broker.
export function ceritaKartu(args: {
symbol: string; verdict: Verdict; alasan: string;
close?: number; closeDate?: string; foreignNet?: number;
}): string {
const parts: string[] = [];
if (args.close) parts.push(`terakhir ${fmtHarga(args.close)}`);
if (args.foreignNet !== undefined) {
parts.push(args.foreignNet >= 0
? `asing beli bersih ${fmtRp(args.foreignNet)}`
: `asing jual bersih ${fmtRp(Math.abs(args.foreignNet))}`);
}
parts.push(args.alasan);
return parts.join(" · ") + ".";
}
// Konteks pasar: berapa Dilirik vs Dilepas + total asing hari ini.
export function konteksPasar(sorotan: { verdict: Verdict }[], foreignTotal?: number): string {
const up = sorotan.filter((s) => s.verdict === "Dilirik").length;
const down = sorotan.filter((s) => s.verdict === "Dilepas").length;
const mood = up > down ? "mayoritas dilirik — pasar lagi berani."
: down > up ? "mayoritas dilepas — pasar lagi hati-hati."
: "pasar campur aduk — belum ada arah jelas.";
let s = `Dari ${sorotan.length} saham pantauan: ${up} dilirik, ${down} dilepas — ${mood}`;
if (foreignTotal !== undefined) {
s += foreignTotal >= 0
? ` Total asing hari ini beli bersih ${fmtRp(foreignTotal)}.`
: ` Total asing hari ini jual bersih ${fmtRp(Math.abs(foreignTotal))}.`;
}
return s;
}
+66 -71
View File
@@ -1,25 +1,25 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Term, IstilahStrip } from "../components/Awam";
import { EmptyState, PageHead } from "../components/ui";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { TextField, TextFieldInput } from "../components/ui/text-field";
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
const OPS = [">=", "<=", ">", "<", "=="];
// Template siap pakai -> rule JSON backend.
const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[] = [
{ label: "🐋 Bandar mulai borong", desc: "Beri tahu kalau broker besar net-beli di saham watchlist", rule: { all: [{ field: "broker_score", op: ">=", value: 30 }] } },
{ label: "🌍 Asing masuk deras", desc: "Beri tahu kalau uang asing inflow besar", rule: { all: [{ field: "foreign_net", op: ">=", value: 50000000000 }] } },
{ label: "🚨 Bandar mulai jualan", desc: "Beri tahu kalau broker besar net-jual", rule: { all: [{ field: "broker_score", op: "<=", value: -30 }] } },
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
];
type Cond = { field: string; op: string; value: number };
export default function Alerts() {
function AlertsInner() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
const [name, setName] = createSignal("accumulation watcher");
const [field, setField] = createSignal("score");
const [op, setOp] = createSignal(">=");
const [value, setValue] = createSignal(60);
const [conds, setConds] = createSignal<Cond[]>([]);
const [tpl, setTpl] = createSignal(TEMPLATES[0]);
const [channels, setChannels] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [dkind, setDkind] = createSignal("telegram");
@@ -28,23 +28,17 @@ export default function Alerts() {
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true });
function addCond() {
setConds((p) => [...p, { field: field(), op: op(), value: value() }]);
}
const [err, setErr] = createSignal("");
async function create() {
setBusy(true);
try {
await api.createAlert({
name: name(),
rule: JSON.parse(rulePreview()),
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
setConds([]);
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
setChannels("");
refetch();
} finally { setBusy(false); }
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
async function del(id: number) { await api.deleteAlert(id); refetch(); }
async function del(id: number) { try { await api.deleteAlert(id); refetch(); } catch (e) { setErr(String(e)); } }
async function addDest() {
setDerr("");
try {
@@ -56,35 +50,49 @@ export default function Alerts() {
refetchDests();
} catch (e) { setDerr(String(e)); }
}
async function toggleDest(id: number, enabled: boolean) {
await api.updateDestination(id, { enabled: !enabled });
refetchDests();
}
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
const fmtDate = (s: unknown) => {
const t = String(s || "");
const d = new Date(t);
return Number.isNaN(d.getTime()) ? t : d.toLocaleString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
};
async function toggleDest(id: number, enabled: boolean) { try { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } catch (e) { setDerr(String(e)); } }
async function delDest(id: number) { try { await api.deleteDestination(id); refetchDests(); } catch (e) { setDerr(String(e)); } }
return (
<div class="space-y-4">
<PageHead title="Alerts" sub="Rules evaluated over snapshots each cycle." />
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card>
<CardHeader>
<CardTitle>Push destinations (per login)</CardTitle>
<CardDescription>Secrets stay server-side — the list never shows tokens.</CardDescription>
</CardHeader>
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 md:grid-cols-2">
<For each={TEMPLATES}>{(t) => (
<Button variant={tpl().label === t.label ? "default" : "outline"} class="h-auto flex-col items-start p-3" onClick={() => setTpl(t)}>
<span class="font-semibold">{t.label}</span><span class="text-xs font-normal opacity-80">{t.desc}</span>
</Button>
)}</For>
</div>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Kirim ke mana? (kosongkan = catat saja)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={create} disabled={busy()}>Pasang notifikasi</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>2. Dikirim ke mana?</CardTitle><CardDescription>Telegram / Discord milikmu. Token tersimpan aman di server.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="flex flex-wrap gap-2">
<div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={dkind() === "telegram" ? "default" : "ghost"} onClick={() => setDkind("telegram")}>Telegram</Button>
<Button size="sm" variant={dkind() === "discord" ? "default" : "ghost"} onClick={() => setDkind("discord")}>Discord</Button>
</div>
<TextField class="w-40"><TextFieldInput placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} /></TextField>
<TextField class="w-40"><TextFieldInput placeholder="nama (mis. HP saya)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} /></TextField>
{dkind() === "telegram" ? (
<>
<TextField class="w-52"><TextFieldInput placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} /></TextField>
<TextField class="w-32"><TextFieldInput placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} /></TextField>
</>
<><TextField class="w-52"><TextFieldInput placeholder="token bot" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} /></TextField>
<TextField class="w-32"><TextFieldInput placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} /></TextField></>
) : (
<TextField class="min-w-64 flex-1"><TextFieldInput placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} /></TextField>
)}
<Button onClick={addDest}>Add destination</Button>
<Button onClick={addDest}>Tambah tujuan</Button>
</div>
<Show when={derr()}><p class="text-sm text-destructive">{derr()}</p></Show>
<ul class="space-y-1.5 text-sm">
@@ -93,61 +101,48 @@ export default function Alerts() {
<li class="flex flex-wrap items-center gap-2">
<Badge variant={d.enabled ? "success" : "secondary"}>{d.kind}</Badge>
<span>{d.label || `#${d.id}`}</span>
<span class="text-muted-foreground">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
<span class="text-muted-foreground">{d.enabled ? "aktif" : "mati"} · {d.configured ? "siap" : "belum ada token"}</span>
<span class="flex-1" />
<Button variant="outline" size="sm" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</Button>
<Button variant="destructive" size="sm" onClick={() => delDest(d.id)}>Delete</Button>
<Button variant="outline" size="sm" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Matikan" : "Nyalakan"}</Button>
<Button variant="destructive" size="sm" onClick={() => delDest(d.id)}>Hapus</Button>
</li>
)}
</For>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>New rule</CardTitle></CardHeader>
<CardContent class="space-y-3">
<TextField><TextFieldInput placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} /></TextField>
<div class="flex flex-wrap gap-2">
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
</select>
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
</select>
<TextField class="w-24"><TextFieldInput type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} /></TextField>
<Button variant="outline" onClick={addCond}>+ condition</Button>
</div>
<Show when={conds().length}>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={conds()}>{(c, i) => <li>{c.field} {c.op} {c.value} <Button variant="ghost" size="sm" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</Button></li>}</For></ul>
</Show>
<p class="text-sm text-muted-foreground">Rule JSON: <code class="font-mono">{rulePreview()}</code></p>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={create} disabled={busy()}>Create rule</Button>
</div>
</CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Rules</CardTitle></CardHeader>
<CardHeader><CardTitle>Notifikasi terpasang</CardTitle></CardHeader>
<CardContent>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex items-center gap-2">{a.name} <span class="text-muted-foreground">last: {a.last_fired || "never"}</span><span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Delete</Button></li>}</For></ul>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="Belum ada" hint="Pilih template di atas, sekali klik." />}>
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex flex-wrap items-center gap-2">
{a.name}
<span class="text-muted-foreground">terakhir: {a.last_fired ? fmtDate(a.last_fired) : "belum pernah"}</span>
<span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Hapus</Button>
</li>}</For></ul>
</Show>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Event history</CardTitle>
<CardTitle>Kejadian terakhir</CardTitle>
<Button variant="ghost" size="sm" onClick={() => refetchEv()}>Refresh</Button>
</CardHeader>
<CardContent>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">No events yet.</p>}>
<ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li><Badge variant="secondary">{String(e.ticker)}</Badge> {String(e.message)}</li>}</For></ul>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kejadian.</p>}>
<ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li class="flex items-start gap-2"><Badge variant="secondary">{String(e.ticker)}</Badge><span class="min-w-0 flex-1">{String(e.message)}</span><span class="shrink-0 text-xs text-muted-foreground">{fmtDate(e.date)}</span></li>}</For></ul>
</Show>
</CardContent>
</Card>
</div>
<div><IstilahStrip /></div>
</div>
);
}
import { Gate } from "../index";
export default function Alerts() {
return <Gate fitur="Notifikasi">{AlertsInner()}</Gate>;
}
+172 -59
View File
@@ -1,80 +1,193 @@
import { createResource, createSignal, onCleanup, For, Show } from "solid-js";
import { api, subscribeSSE, fmtIDR } from "../lib/api";
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards";
import { Citations } from "../components/Citations";
import { createResource, createSignal, createEffect, For, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router";
import { api } from "../lib/api";
import { useAuth } from "../components/auth";
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Skeleton } from "../components/ui/skeleton";
import { Line } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
export default function Dashboard() {
const [health] = createResource(() => api.health());
const [flow, { refetch }] = createResource(() => api.flowSummary());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 10)));
const [foreign] = createResource(() => api.flowForeign("BBCA").catch(() => null));
const navigate = useNavigate();
const { me } = useAuth();
// screen+alertEvents are login-gated; source = me() so login/logout refetches.
const [flow] = createResource(() => api.flowSummary());
const [screen] = createResource(me, (user) => (user ? api.screen({ limit: 5 }).catch(() => null) : null));
const [briefing] = createResource(() => api.briefing().catch(() => null));
const [live, setLive] = createSignal<string[]>([]);
const stop = subscribeSSE((ch, data) => {
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5));
if (ch === "alerts" || ch === "activity") { refetch(); refetchEv(); }
const [events] = createResource(me, (user) => (user ? api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []) : []));
const [chartTiker, setChartTiker] = createSignal("BBCA");
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
// Full picker list = every ticker that has foreign-flow rows.
const fkTickers = () => flow()?.foreign_tickers || [];
// Keep the current chart ticker valid even when the list changes.
createEffect(() => {
const list = fkTickers();
if (list.length && !list.includes(chartTiker())) setChartTiker(list[0]);
});
onCleanup(stop);
const flowChart = () => ({
const ringkasan = () => {
const n = briefing()?.narasi?.trim();
if (n) return n.split("\n").map((s) => s.replace(/^[-•\d.]+\s*/, "")).filter(Boolean).slice(0, 3);
const p = briefing()?.payload;
return p ? heroSummary(p) : [];
};
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c]));
// Resolve theme-aware primary color (falls back to blue) so the chart
// matches light/dark instead of a hardcoded hex.
const primaryColor = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue("--primary");
const [h, s, l] = c.trim().split(/[\s,]+/).map(Number);
return Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l) ? `hsl(${h} ${s}% ${l}%)` : "#3b82f6";
};
const chartData = () => ({
labels: (foreign()?.dates || []) as string[],
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }],
datasets: [{
label: `${chartTiker()} — uang asing harian (Rp juta)`,
data: (foreign()?.nets || []) as number[],
borderColor: primaryColor(),
backgroundColor: "transparent",
fill: true,
tension: 0.25,
}],
});
const chartOpts = () => ({
responsive: true,
plugins: {
tooltip: { callbacks: { label: (c: { parsed: { y: number } }) => ` ${fmtRp(c.parsed.y)}` } },
legend: { display: false },
},
scales: {
y: { ticks: { callback: (v: number | string) => fmtRp(Number(v)), maxTicksLimit: 5 } },
},
});
const periode = () => {
const f = foreign();
return f?.start && f?.end ? `${f.start} → ${f.end}` : "";
};
return (
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Smart Money Dashboard</h1>
<p class="text-sm text-muted-foreground">
Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}
</p>
</div>
<Show when={health()?.stale_flags?.length}>
<p class="text-sm text-muted-foreground">Stale: <For each={health()!.stale_flags}>{(f) => <Badge variant="outline">{f}</Badge>}</For></p>
</Show>
<Show when={!flow.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Foreign net total</CardTitle><CardDescription>Aggregate across snapshots.</CardDescription></CardHeader>
<CardContent>
<p class="font-mono text-2xl font-bold">{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p>
<Citations items={flow()?.citations} />
</CardContent>
</Card>
<AccumulationTable rows={flow()?.top_accumulation || []} />
<RotationMap signal={undefined} citations={flow()?.citations} />
<Card>
<CardHeader><CardTitle>Foreign flow (BBCA)</CardTitle></CardHeader>
<CardContent>
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">No foreign-flow series yet.</p>}>
<Line data={flowChart()} options={{ responsive: true }} />
<Show when={foreign()?.reversal}><p class="text-sm">Reversal flag set.</p></Show>
</Show>
</CardContent>
</Card>
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
<Card>
<CardHeader><CardTitle>Live agent panel</CardTitle></CardHeader>
<CardContent>
<Show when={live().length} fallback={<p class="text-sm text-muted-foreground">Waiting for SSE events…</p>}>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={live()}>{(l) => <li>{l}</li>}</For></ul>
</Show>
</CardContent>
</Card>
</div>
</Show>
<Card>
<CardHeader><CardTitle>Morning briefing</CardTitle></CardHeader>
{/* HERO: hari ini ada apa */}
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader>
<CardTitle class="text-2xl">Hari ini ada apa? 📈</CardTitle>
<CardDescription>Ringkasan pasar pagi ini — tanpa perlu paham istilah ekonomi.</CardDescription>
</CardHeader>
<CardContent>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — subscribe to the morning-briefing routine.</p>}>
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
<Show when={!briefing.loading} fallback={<div class="space-y-2"><Skeleton class="h-4 w-full" /><Skeleton class="h-4 w-3/4" /></div>}>
<Show when={ringkasan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada ringkasan hari ini — coba lagi setelah scheduler jalan.</p>}>
<ul class="space-y-2 text-base leading-relaxed">
<For each={ringkasan()}>{(k) => <li>• {k}</li>}</For>
</ul>
</Show>
</Show>
<Show when={sorotan().length}>
<p class="mt-3 border-t pt-3 text-sm text-muted-foreground">🧭 {konteksPasar(sorotan(), flow()?.foreign_net_total)}</p>
</Show>
<div class="mt-3"><IstilahStrip /></div>
</CardContent>
</Card>
{/* SOROTAN SAHAM */}
<div>
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2>
<Show when={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-44" /><Skeleton class="h-44" /></div>}>
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}>
<div class="grid gap-4 md:grid-cols-2">
<For each={sorotan()}>{(s) => {
const b = (s.row.breakdown || {}) as Record<string, unknown>;
const cx = closeMap()[s.row.symbol];
return (
<Card class={s.verdict === "Dilirik" ? "border-emerald-500/50" : s.verdict === "Dilepas" ? "border-red-500/50" : ""}>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle class="text-xl font-bold">
{s.row.symbol}
<Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show>
</CardTitle>
<Show when={s.row.name && s.row.name !== s.row.symbol}>
<p class="text-xs text-muted-foreground">{s.row.name}</p>
</Show>
</div>
<VerdictBadge verdict={s.verdict} />
</CardHeader>
<CardContent class="space-y-3">
<p class="text-sm">
Harga terakhir <strong class="font-mono">{cx?.close ? fmtHarga(cx.close) : "—"}</strong>
{" "}— {s.alasan}.
</p>
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
<div class="flex gap-2">
<Button size="sm" onClick={() => navigate(`/report/${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
</div>
</CardContent>
</Card>
);
}}</For>
</div>
</Show>
</Show>
</div>
{/* UANG ASING PER SAHAM */}
<Card>
<CardHeader>
<CardTitle>Uang asing lagi ke mana?</CardTitle>
<CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-3 flex flex-wrap items-center gap-2">
<label class="sr-only" for="fk-ticker">Pilih saham</label>
<select id="fk-ticker" class="h-9 rounded-md border bg-background px-2 text-sm"
value={chartTiker()} onChange={(e) => setChartTiker((e.currentTarget as HTMLSelectElement).value)}>
<For each={fkTickers()}>
{(t) => <option value={t}>{t}</option>}
</For>
</select>
<span class="text-xs text-muted-foreground">{fkTickers().length} saham tersedia</span>
<span class="flex-1" />
<For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) =>
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
}</For>
</div>
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
<Line data={chartData()} options={chartOpts() as never} />
<p class="mt-1 text-xs text-muted-foreground">Periode {periode()} · sumber: Sectors API (lihat sitasi report).</p>
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
</Show>
</CardContent>
</Card>
{/* AKTIVITAS + ANGKA MENTAH LIPAT */}
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Kabar terbaru</CardTitle><CardDescription><Term kata="alert" /> dan aktivitas sistem.</CardDescription></CardHeader>
<CardContent>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kabar — pasang alert biar dapat notifikasi.</p>}>
<ul class="space-y-2 text-sm">
<For each={events() || []}>{(e) => <li>• <strong>{String(e.ticker || "")}</strong> — {String(e.message || "")} <span class="text-muted-foreground">({String(e.date || "")})</span></li>}</For>
</ul>
</Show>
<div class="mt-3"><Button size="sm" variant="outline"><A href="/alerts">Atur notifikasi saya</A></Button></div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker 5 hari: <strong>{flow() ? fmtRp(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
<CardContent>
<ul class="space-y-1 text-sm">
<For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report/${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtRp(t.net_sum)} · {t.brokers} broker</span></li>}</For>
</ul>
<div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
</CardContent>
</Card>
</div>
</div>
);
}
+56 -56
View File
@@ -1,87 +1,87 @@
import { createResource, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Term, IstilahStrip } from "../components/Awam";
import { Bar } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
import { PageHead } from "../components/ui";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
Chart.register(...registerables);
export default function Portfolio() {
function PortfolioInner() {
const [risk] = createResource(() => api.risk());
const [acc] = createResource(() => api.accuracy());
const conc = () => risk()?.concentration || [];
const betaN = () => Number(risk()?.beta ?? 1);
const betaArti = () => {
const b = betaN();
if (b > 1.2) return "bergerak lebih liar dari pasar — naik lebih kencang, turun lebih dalam.";
if (b < 0.8) return "lebih kalem dari pasar — cocok buat yang tidak suka deg-degan.";
return "geraknya seirama pasar — tidak terlalu liar, tidak terlalu kalem.";
};
const saran = () => {
const c = conc();
if (!c.length) return "";
// Sektor dulu (risiko sistemik) baru bobot single-ticker.
const perSek: Record<string, number> = {};
for (const x of c) perSek[x.sector] = (perSek[x.sector] || 0) + x.weight;
const [sekTop, wSek] = Object.entries(perSek).sort((a, b) => b[1] - a[1])[0];
if (wSek > 0.4) return `Sektor ${sekTop} porsinya ${(wSek * 100).toFixed(0)}% — kalau sektor itu jatuh, semua ikut. Coba lirik sektor lain.`;
const top = c[0];
if (top.weight > 0.4) return `${top.ticker} porsinya ${(top.weight * 100).toFixed(0)}% — kebanyakan telur di satu keranjang. Pertimbangkan tambah saham beda sektor.`;
return "Sebarannya sudah lumayan — tidak numpuk di satu tempat. Pertahankan. 👍";
};
const primaryColor = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue("--primary");
const [h, s, l] = c.trim().split(/[\s,]+/).map(Number);
return Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l) ? `hsl(${h} ${s}% ${l}%)` : "#3b82f6";
};
const chartData = () => ({
labels: (risk()?.concentration || []).map((c) => c.ticker),
datasets: [{ label: "Weight %", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
labels: conc().map((c) => c.ticker),
datasets: [{
label: "Porsi %",
data: conc().map((c) => +(c.weight * 100).toFixed(1)),
backgroundColor: conc().map((_, i) => {
const alphas = ["88", "66", "44", "33", "22"];
return `${primaryColor()}${alphas[i % alphas.length]}`;
}),
borderRadius: 4,
}],
});
return (
<div class="space-y-4">
<PageHead title="Portfolio Risk + Accuracy Ledger" sub="Computed from your watchlist." />
<Show when={!risk.loading && !acc.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
<PageHead title="Risiko portofolio kamu 💼" sub="Dihitung dari watchlist-mu. Bahasa manusia, bukan laporan bank." />
<Show when={!risk.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader><CardTitle class="text-xl">Saran 1 kalimat 💡</CardTitle></CardHeader>
<CardContent><p class="text-base leading-relaxed">{saran() || "Tambah saham ke watchlist dulu biar bisa dinilai."}</p></CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Concentration</CardTitle></CardHeader>
<CardHeader><CardTitle>Porsi tiap saham</CardTitle><CardDescription>Siapa yang paling dominan di <Term kata="portofolio" />-mu.</CardDescription></CardHeader>
<CardContent>
<Show when={(risk()?.concentration || []).length} fallback={<p class="text-sm text-muted-foreground">Add tickers to your watchlist to see concentration.</p>}>
<Show when={conc().length} fallback={<p class="text-sm text-muted-foreground">Tambah saham ke watchlist dulu.</p>}>
<Bar data={chartData()} options={{ responsive: true }} />
</Show>
<For each={risk()?.warnings || []}>{(w) => <p class="text-sm">⚠️ {w}</p>}</For>
<For each={risk()?.warnings || []}>{(w) => <p class="mt-1 text-sm">⚠️ {w}</p>}</For>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Beta vs index</CardTitle></CardHeader>
<CardHeader><CardTitle>Kelakuan vs pasar</CardTitle><CardDescription>Skor beta = {betaN().toFixed(2)}</CardDescription></CardHeader>
<CardContent class="space-y-3">
<p class="font-mono text-2xl font-bold">{risk() ? String(risk()!.beta) : "…"}</p>
<h3 class="text-sm font-semibold">Correlation heatmap</h3>
<CorrHeatmap matrix={risk()?.correlation || {}} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Agent accuracy</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Agent</TableHead><TableHead class="text-right">Calls</TableHead><TableHead class="text-right">Resolved</TableHead><TableHead class="text-right">Hit %</TableHead></TableRow></TableHeader>
<TableBody>
<For each={acc()?.agents || []}>
{(a) => <TableRow><TableCell>{a.agent}</TableCell><TableCell class="text-right">{a.calls}</TableCell><TableCell class="text-right">{a.resolved}</TableCell><TableCell class="text-right">{(a.hit_rate * 100).toFixed(0)}%</TableCell></TableRow>}
</For>
</TableBody>
</Table>
<p class="text-base">Portofolio kamu <strong>{betaArti()}</strong></p>
<div><Badge variant="outline"><Term kata="diversifikasi" />: {[...new Set(conc().map((x) => x.sector))].length} sektor</Badge></div>
</CardContent>
</Card>
</div>
</Show>
<div><IstilahStrip /></div>
</div>
);
}
function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> }) {
const keys = () => Object.keys(props.matrix);
const cell = (v: number) => {
const a = Math.min(1, Math.abs(v));
return v >= 0 ? `rgba(34,197,94,${(0.12 + 0.6 * a).toFixed(2)})` : `rgba(239,68,68,${(0.12 + 0.6 * a).toFixed(2)})`;
};
return (
<Show when={keys().length} fallback={<p class="text-sm text-muted-foreground">No correlation data yet.</p>}>
<div class="overflow-x-auto"><Table>
<TableHeader><TableRow><TableHead></TableHead><For each={keys()}>{(k) => <TableHead>{k}</TableHead>}</For></TableRow></TableHeader>
<TableBody>
<For each={keys()}>
{(r) => (
<TableRow>
<TableCell class="font-bold">{r}</TableCell>
<For each={keys()}>
{(c) => {
const v = props.matrix[r]?.[c] ?? 0;
return <TableCell style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</TableCell>;
}}
</For>
</TableRow>
)}
</For>
</TableBody>
</Table></div>
</Show>
);
import { Gate } from "../index";
export default function Portfolio() {
return <Gate fitur="Portofolio">{PortfolioInner()}</Gate>;
}
+42 -14
View File
@@ -1,7 +1,8 @@
import { createSignal, For, Show, onMount } from "solid-js";
import { createSignal, createEffect, For, Show, on } from "solid-js";
import { useParams } from "@solidjs/router";
import { api, type ReportPayload } from "../lib/api";
import { Citations } from "../components/Citations";
import { Term } from "../components/Awam";
import { PageHead, RecBadge } from "../components/ui";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
@@ -9,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
export default function Report() {
function ReportInner() {
const params = useParams();
const [rep, setRep] = createSignal<ReportPayload | null>(null);
const [md, setMd] = createSignal("");
@@ -18,26 +19,34 @@ export default function Report() {
const [busy, setBusy] = createSignal(true);
const [asking, setAsking] = createSignal(false);
const [err, setErr] = createSignal("");
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
async function load() {
setErr(""); setBusy(true);
try { setRep(await api.report(params.ticker)); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
onMount(load);
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
// Refetch when the ticker param changes (ReportInner stays mounted
// because Gate wraps it, but params.ticker is reactive).
createEffect(on(() => params.ticker, () => load()));
async function exportMd() {
try { setMd(await api.reportMd(params.ticker)); }
catch (e) { setErr(String(e)); }
}
async function ask() {
if (!question().trim()) return;
setAsking(true);
try {
const r = await api.interrogate(params.ticker, question());
setAnswer(r.answer);
} catch (e) {
setErr(String(e));
} finally { setAsking(false); }
}
function dl(url: string, name: string) {
const a = document.createElement("a");
a.href = url; a.download = name; a.click();
// Revoke after a short delay to avoid blob URL leak.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
return (
<div class="space-y-4">
@@ -48,6 +57,12 @@ export default function Report() {
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-32 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
<Show when={rep()}>
<Show when={rep()!.narasi_awam}>
<Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader><CardTitle class="text-xl">Kesimpulan buat kamu 🤝</CardTitle></CardHeader>
<CardContent><p class="whitespace-pre-wrap text-base leading-relaxed">{rep()!.narasi_awam}</p></CardContent>
</Card>
</Show>
<Card class="border-l-4 border-l-primary">
<CardContent class="flex flex-wrap items-center gap-2 pt-6">
<RecBadge rec={rep()!.synthesis.recommendation} />
@@ -65,29 +80,36 @@ export default function Report() {
<CardContent class="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportMd}>Markdown</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
try {
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=html`, { method: "POST", credentials: "same-origin" });
if (!r.ok) { setErr(`HTML export gagal: HTTP ${r.status}`); return; }
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
} catch (e) { setErr(String(e)); }
}}>HTML</Button>
<Button variant="outline" onClick={() => {
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
}}>JSON</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
try {
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=pdf`, { method: "POST", credentials: "same-origin" });
if (!r.ok) { setErr(`PDF export gagal: HTTP ${r.status}`); return; }
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
} catch (e) { setErr(String(e)); }
}}>PDF</Button>
</CardContent>
</Card>
<For each={rep()!.sections}>
{(s) => <Card><CardHeader><CardTitle>{s.name}</CardTitle></CardHeader><CardContent><p class="whitespace-pre-wrap text-sm">{s.body}</p><div class="mt-2"><Citations items={s.citations} /></div></CardContent></Card>}
{(s) => <Card><CardHeader><CardTitle class="capitalize">{s.name}</CardTitle></CardHeader><CardContent><p class="whitespace-pre-wrap text-sm">{s.body}</p><div class="mt-2"><Citations items={s.citations} /></div></CardContent></Card>}
</For>
<Card>
<CardHeader><CardTitle>Interrogate this report</CardTitle></CardHeader>
<CardHeader><CardTitle>Tanya soal report ini 💬</CardTitle></CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Ask about this report…" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} /></TextField>
<Button onClick={ask} disabled={asking()}>{asking() ? "…" : "Ask"}</Button>
<TextField class="flex-1"><TextFieldInput placeholder="Mis. kenapa conviction-nya segitu?" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} /></TextField>
<Button onClick={ask} disabled={asking()}>{asking() ? "…" : "Tanya"}</Button>
</div>
<Show when={answer()}><p class="text-sm">{answer()}</p></Show>
<Show when={answer()}><p class="whitespace-pre-wrap text-sm">{answer()}</p></Show>
<p class="text-xs text-muted-foreground">Jawaban diambil dari data report ini + <Term kata="citations" /> — bukan karangan.</p>
</CardContent>
</Card>
</Show>
@@ -95,3 +117,9 @@ export default function Report() {
</div>
);
}
import { Gate } from "../index";
export default function Report() {
return <Gate fitur="Report saham"><ReportInner /></Gate>;
}
+68 -42
View File
@@ -1,73 +1,81 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Citations } from "../components/Citations";
import { Term, IstilahStrip } from "../components/Awam";
import { EmptyState, PageHead } from "../components/ui";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { TextField, TextFieldInput } from "../components/ui/text-field";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"];
// Tipe -> penjelasan manfaat bahasa awam.
const MANFAAT: Record<string, { judul: string; desc: string }> = {
"morning-briefing": { judul: "☀️ Ringkasan pagi", desc: "Dapat kabar pasar tiap pagi jam 07:30 — siapa diborong, ke mana uang asing." },
"accumulation-radar": { judul: "🐋 Radar borongan bandar", desc: "Dikabari kalau broker besar mulai borong saham." },
"foreign-reversal": { judul: "🌍 Pantau balik arah asing", desc: "Dikabari kalau uang asing berbalik arah (masuk ↔ keluar)." },
"insider-tape": { judul: "🕵️ Gerak orang dalam", desc: "Dikabari kalau direksi/pemilik saham ikut beli." },
"earnings-countdown": { judul: "📊 Pengingat laporan keuangan", desc: "Dikabari sebelum emiten rilis laporan keuangan." },
"dividend-calendar": { judul: "💰 Pengingat dividen", desc: "Dikabari sebelum tanggal bagi dividen (biar tidak kelewat)." },
"weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
};
export default function Routines() {
function RoutinesInner() {
const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
const [schedule, setSchedule] = createSignal("");
const [channels, setChannels] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() {
setBusy(true);
try {
await api.createRoutine({
type: type_(),
schedule_cron: schedule().trim() || undefined,
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
refetch();
} finally { setBusy(false); }
try { await api.createRoutine({ type: type_() }); refetch(); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
const fmtTime = (s: unknown) => {
const t = String(s || "");
if (!t) return "—";
// ISO timestamp → "16 Sep 07:30" (local time).
const d = new Date(t);
return Number.isNaN(d.getTime()) ? t : d.toLocaleString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
};
async function toggle(id: number, enabled: boolean) {
await api.updateRoutine(id, { enabled: !enabled });
refetch();
try { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } catch (e) { setErr(String(e)); }
}
async function del(id: number) {
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
refetch();
try { await api.deleteRoutine(id); refetch(); } catch (e) { setErr(String(e)); }
}
return (
<div class="space-y-4">
<PageHead title="Routine Manager" sub="Scheduled deliveries with citations." />
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card>
<CardHeader><CardTitle>Subscribe</CardTitle></CardHeader>
<CardContent class="flex flex-wrap gap-2">
<TextField class="w-52">
<select class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
<For each={TYPES}>{(t) => <option value={t}>{t}</option>}</For>
</select>
</TextField>
<TextField class="w-60"><TextFieldInput placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} /></TextField>
<TextField class="w-52"><TextFieldInput placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
<Button onClick={subscribe} disabled={busy()}>Subscribe</Button>
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 md:grid-cols-2">
<For each={Object.entries(MANFAAT)}>{([tipe, m]) => (
<Button variant={type_() === tipe ? "default" : "outline"} class="h-auto flex-col items-start p-3" onClick={() => setType(tipe)}>
<span class="font-semibold">{m.judul}</span><span class="text-xs font-normal opacity-80">{m.desc}</span>
</Button>
)}</For>
</div>
<Button onClick={subscribe} disabled={busy()}>Aktifkan ✓</Button>
</CardContent>
</Card>
<Show when={(data()?.routines || []).length} fallback={
<EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." />
<EmptyState icon="🗓️" title="Belum ada jadwal" hint="Pilih layanan di atas — sekali klik langsung jalan." />
}>
<div class="grid gap-4 md:grid-cols-2">
<For each={data()?.routines || []}>
{(r) => (
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-base">{r.type}</CardTitle>
<Badge variant={r.enabled ? "success" : "secondary"}>{r.enabled ? "on" : "off"}</Badge>
<CardTitle class="text-base">{MANFAAT[r.type]?.judul || r.type}</CardTitle>
<Badge variant={r.enabled ? "success" : "secondary"}>{r.enabled ? "jalan" : "mati"}</Badge>
</CardHeader>
<CardContent class="space-y-2">
<p class="font-mono text-xs text-muted-foreground">{r.schedule_cron}</p>
<p class="text-sm text-muted-foreground">{MANFAAT[r.type]?.desc || ""}</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</Button>
<Button variant="destructive" size="sm" onClick={() => del(r.id)}>Delete</Button>
<Button variant="outline" size="sm" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Matikan" : "Nyalakan"}</Button>
<Button variant="destructive" size="sm" onClick={() => del(r.id)}>Hapus</Button>
</div>
</CardContent>
</Card>
@@ -77,24 +85,42 @@ export default function Routines() {
</Show>
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Latest briefing</CardTitle></CardHeader>
<CardHeader><CardTitle>Ringkasan terakhir</CardTitle><CardDescription><Term kata="briefing" /> pagi yang sudah terbit.</CardDescription></CardHeader>
<CardContent>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — it generates after the first morning-briefing run.</p>}>
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
<div class="mt-2"><Citations items={(briefing() as { citations: string })?.citations} /></div>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">Belum terbit — aktifkan "Ringkasan pagi" di atas.</p>}>
<Show when={(briefing() as { narasi?: string })?.narasi} fallback={<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 600)}</pre>}>
<p class="whitespace-pre-wrap text-sm leading-relaxed">{(briefing() as { narasi?: string })?.narasi}</p>
</Show>
</Show>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Run history</CardTitle>
<CardTitle>Riwayat jalan</CardTitle>
<Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button>
</CardHeader>
<CardContent>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={runs() || []}>{(r) => <li>{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
<ul class="space-y-1 text-sm text-muted-foreground">
<For each={runs() || []}>{(r) => (
<li class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<Badge variant={r.status === "ok" ? "success" : r.status === "error" ? "error" : "secondary"} class="capitalize">{String(r.status)}</Badge>
<span>{fmtTime(r.started_at)}</span>
</span>
<span class="font-mono text-xs">{r.credits_used ? `${String(r.credits_used)} kredit` : `#${String(r.routine_id)}`}</span>
</li>
)}</For>
</ul>
</CardContent>
</Card>
</div>
<div><IstilahStrip /></div>
</div>
);
}
import { Gate } from "../index";
export default function Routines() {
return <Gate fitur="Jadwal otomatis">{RoutinesInner()}</Gate>;
}
+80 -93
View File
@@ -1,126 +1,113 @@
import { createSignal, For, Show } from "solid-js";
import { createSignal, For, Show, onMount } from "solid-js";
import { api, type ScreenRow } from "../lib/api";
import { Citations } from "../components/Citations";
import { BreakdownBars, EmptyState, PageHead, RecBadge } from "../components/ui";
import { verdictFor } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
import { Switch, SwitchControl, SwitchLabel, SwitchThumb } from "../components/ui/switch";
import { useNavigate } from "@solidjs/router";
const SAVED_KEY = "fs-saved-screeners";
// Preset awam -> body /api/screen. PRESET_SEMUA = default auto-jalan.
export const PRESET_SEMUA = { label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } };
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
PRESET_SEMUA,
];
function scoreOf(r: ScreenRow): number {
const b = r.breakdown as Record<string, unknown>;
for (const k of ["composite", "score", "broker_score", "broker"]) {
if (typeof b[k] === "number") return b[k] as number;
}
return r.composite || 0;
}
export default function Screener() {
const [mode, setMode] = createSignal<"where" | "q">("q");
const [q, setQ] = createSignal("");
const [brokerMin, setBrokerMin] = createSignal(0);
const [foreignOnly, setForeignOnly] = createSignal(false);
const [insiderOnly, setInsiderOnly] = createSignal(false);
function ScreenerInner() {
const navigate = useNavigate();
const [rows, setRows] = createSignal<ScreenRow[]>([]);
const [ran, setRan] = createSignal(false);
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const loadSaved = (): Record<string, string> => {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
};
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
const [sname, setSname] = createSignal("");
async function run() {
setErr(""); setBusy(true);
const [aktif, setAktif] = createSignal("");
const [q, setQ] = createSignal("");
const [showCustom, setShowCustom] = createSignal(false);
async function runPreset(p: (typeof PRESETS)[number]) {
setErr(""); setBusy(true); setAktif(p.label); setShowCustom(false);
try {
const institutional: Record<string, unknown> = { broker_score_min: brokerMin() };
if (foreignOnly()) institutional.foreign_inflow = true;
if (insiderOnly()) institutional.insider_buying = true;
const body = mode() === "q"
? { q: q() || undefined, institutional, limit: 20 }
: { where: q() || undefined, institutional, limit: 20 };
const r = await api.screen(body);
const r = await api.screen(p.body);
setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
function save() {
const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() };
localStorage.setItem(SAVED_KEY, JSON.stringify(all));
setSaved(all);
async function runCustom() {
const query = q().trim();
if (!query) return;
setErr(""); setBusy(true); setAktif("custom");
try {
const r = await api.screen({ q: query, limit: 20 });
setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
onMount(() => { if (!ran()) void runPreset(PRESET_SEMUA); });
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
return (
<div class="space-y-4">
<PageHead title="Institutional Screener" sub="Rank tickers by smart-money signals." />
<Card>
<CardContent class="space-y-3 pt-6">
<div class="flex flex-wrap items-center gap-2">
<div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={mode() === "q" ? "default" : "ghost"} onClick={() => setMode("q")}>Natural language</Button>
<Button size="sm" variant={mode() === "where" ? "default" : "ghost"} onClick={() => setMode("where")}>SQL-like</Button>
</div>
<TextField class="min-w-52 flex-1">
<TextFieldInput placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} />
</TextField>
<Button onClick={run} disabled={busy()}>{busy() ? "Screening…" : "Screen"}</Button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2 text-muted-foreground">broker ≥
<TextField class="w-20"><TextFieldInput type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} /></TextField>
</label>
<Switch checked={foreignOnly()} onChange={setForeignOnly}>
<SwitchControl><SwitchThumb /></SwitchControl>
<SwitchLabel>foreign inflow</SwitchLabel>
</Switch>
<Switch checked={insiderOnly()} onChange={setInsiderOnly}>
<SwitchControl><SwitchThumb /></SwitchControl>
<SwitchLabel>insider buying</SwitchLabel>
</Switch>
</div>
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
</CardContent>
</Card>
<div>
<h1 class="text-2xl font-bold tracking-tight">Cari saham menarik 🔍</h1>
<p class="text-sm text-muted-foreground">Pilih satu kategori — sistem yang menyaring <Term kata="screener" /> buat kamu.</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<For each={PRESETS}>{(p) => (
<Card class={aktif() === p.label ? "border-primary" : ""}>
<CardHeader class="pb-2"><CardTitle class="text-base">{p.label}</CardTitle><CardDescription>{p.desc}</CardDescription></CardHeader>
<CardContent><Button size="sm" onClick={() => runPreset(p)} disabled={busy()}>{busy() && aktif() === p.label ? "Menyaring…" : "Tampilkan"}</Button></CardContent>
</Card>
)}</For>
<Card class={showCustom() ? "border-primary" : ""}>
<CardHeader class="pb-2"><CardTitle class="text-base">🔎 Cari kode saham</CardTitle><CardDescription>Ketik kode saham atau nama (mis. "bca", "tlkm", "bank").</CardDescription></CardHeader>
<CardContent class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="mis. bca, tlkm, bbri…" value={q()} onInput={(e) => { setQ(e.currentTarget.value); setShowCustom(true); }} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") runCustom(); }} /></TextField>
<Button size="sm" onClick={runCustom} disabled={busy() || !q().trim()}>{busy() && aktif() === "custom" ? "Menyaring…" : "Cari"}</Button>
</CardContent>
</Card>
</div>
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
<Show when={ran() && !busy()}>
<Show when={rows().length} fallback={<EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." />}>
<Show when={hasil().length} fallback={<p class="text-sm text-muted-foreground">Tidak ada yang cocok — coba kategori lain.</p>}>
<h2 class="text-lg font-semibold">Hasil ({hasil().length})</h2>
<div class="grid gap-4 md:grid-cols-2">
<For each={rows()}>
{(r) => (
<For each={hasil()}>{(s) => {
const b = (s.row.breakdown || {}) as Record<string, unknown>;
return (
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<a class="font-bold text-primary hover:underline" href={`/report/${r.symbol}`}>{r.symbol}</a>
<Badge variant="secondary" class="font-mono">{r.composite.toFixed(1)}</Badge>
<div>
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
<Show when={s.row.name && s.row.name !== s.row.symbol}>
<p class="text-sm text-muted-foreground">{s.row.name}</p>
</Show>
</div>
<VerdictBadge verdict={s.verdict} />
</CardHeader>
<CardContent class="space-y-2">
<BreakdownBars breakdown={r.breakdown as Record<string, unknown>} />
<Citations items={r.citations} />
<Show when={scoreOf(r) !== 0}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></Show>
<CardContent class="space-y-3">
<p class="text-sm">Karena: <strong>{s.alasan}</strong>.</p>
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
<div class="flex gap-2">
<Button size="sm" onClick={() => navigate(`/report/${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
</div>
</CardContent>
</Card>
)}
</For>
);
}}</For>
</div>
</Show>
</Show>
<Show when={!ran() && !busy()}>
<EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." />
</Show>
<Card>
<CardHeader><CardTitle>Saved screeners</CardTitle></CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2">
<TextField class="w-36"><TextFieldInput placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} /></TextField>
<Button variant="outline" onClick={save}>Save current</Button>
</div>
<ul class="space-y-1 text-sm">
<For each={Object.entries(saved())}>{([n, query]) => <li><Button variant="ghost" size="sm" onClick={() => { setQ(query); run(); }}>{n}</Button> <span class="text-muted-foreground">{query.slice(0, 60)}</span></li>}</For>
</ul>
</CardContent>
</Card>
<div><IstilahStrip /></div>
</div>
);
}
import { Gate } from "../index";
export default function Screener() {
return <Gate fitur="Cari saham"><ScreenerInner /></Gate>;
}