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

This commit is contained in:
asepharyana
2026-09-16 01:06:42 +07:00
parent 07ece10474
commit 6fd89b6c19
8 changed files with 275 additions and 14 deletions
+1
View File
@@ -48,3 +48,4 @@ Thumbs.db
task_plan.md task_plan.md
findings.md findings.md
progress.md progress.md
backend/universe-sweep
+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)
}
+25 -1
View File
@@ -17,6 +17,8 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
if date == "" { if date == "" {
date = "latest" 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)) wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 { if len(wl) == 0 {
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 { if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
@@ -31,10 +33,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
Brokers int `json:"brokers"` Brokers int `json:"brokers"`
} }
var accs []accRow var accs []accRow
var fkTickers []string
foreignTotal := 0.0 foreignTotal := 0.0
var closes []map[string]any var closes []map[string]any
var cites []model.Citation 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 { if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
sum, n := 0.0, 0 sum, n := 0.0, 0
for _, v := range nets { for _, v := range nets {
@@ -48,12 +60,23 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
} }
if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 { if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
foreignTotal += nets[len(nets)-1] foreignTotal += nets[len(nets)-1]
fkTickers = append(fkTickers, tk)
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date)) cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
} }
if px, dx, err := s.DB.LatestClose(tk); err == nil { if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx}) 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).
if len(wl) > 60 {
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 }) sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 { if len(accs) > 5 {
accs = accs[:5] accs = accs[:5]
@@ -61,6 +84,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal, "date": date, "foreign_net_total": foreignTotal,
"top_accumulation": accs, "closes": closes, "citations": cites, "top_accumulation": accs, "closes": closes, "citations": cites,
"foreign_tickers": fkTickers,
}) })
} }
+90 -7
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/url" "net/url"
"strconv"
"time" "time"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -175,6 +176,15 @@ func (s *Scheduler) RunCycle(ctx context.Context) error {
return err 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 { if err := s.events(ctx); err != nil {
log.Printf("scheduler: events: %v", err) log.Printf("scheduler: events: %v", err)
} }
@@ -234,22 +244,53 @@ func (s *Scheduler) reference(ctx context.Context) error {
return nil 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 { 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 offset := 0
for page := 0; page < 12; page++ { var all []sectors.CloseRow
rows, total, err := s.Sectors.ClosePage(ctx, "", 30, offset) for page := 0; page < 40; page++ {
rows, _, err := s.Sectors.ClosePage(ctx, "", 30, offset)
if err != nil { if err != nil {
// Rate-limited mid-sweep: keep pages collected so far if any.
if len(all) > 0 {
break
}
return err return err
} }
if raw, err := json.Marshal(rows); err == nil && len(rows) > 0 { all = append(all, rows...)
_ = s.DB.SaveSnapshot("IDX", rows[0].Date, "close", string(raw))
}
offset += len(rows) offset += len(rows)
if offset >= total || len(rows) == 0 { if len(rows) == 0 {
break 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 return nil
} }
@@ -319,6 +360,48 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
return nil 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 := 10 // 3 credits/ticker = 30 credits; leaves headroom within the 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. // events polls news/filings/suspensions incrementally via meta cursors.
func (s *Scheduler) events(ctx context.Context) error { func (s *Scheduler) events(ctx context.Context) error {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
@@ -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 ''
);
+69 -3
View File
@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -545,23 +546,88 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
// default universe: dashboard, screener fallback, and scheduler depth all use // default universe: dashboard, screener fallback, and scheduler depth all use
// it when a user has no personal watchlist. // it when a user has no personal watchlist.
func (db *DB) AllTickers() ([]string, error) { func (db *DB) AllTickers() ([]string, error) {
rows, err := db.Query(`SELECT DISTINCT ticker FROM snapshots // 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`) WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
if err != nil { if err != nil {
return nil, err 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() defer rows.Close()
var out []string out := map[string]bool{}
for rows.Next() { for rows.Next() {
var t string var t string
if err := rows.Scan(&t); err != nil { if err := rows.Scan(&t); err != nil {
return nil, err return nil, err
} }
out = append(out, t) out[t] = true
} }
return out, rows.Err() 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
}
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) VALUES(?,?,?)`,
t, r.Close, r.Date); err != nil {
return err
}
}
return tx.Commit()
}
// AddWatch inserts a ticker (idempotent). // AddWatch inserts a ticker (idempotent).
func (db *DB) AddWatch(userKey, ticker string) error { func (db *DB) AddWatch(userKey, ticker string) error {
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?) _, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
+1 -1
View File
@@ -25,7 +25,7 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
} }
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean } 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 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 }[]; closes: { ticker: string; close: number; date: string }[]; 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 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 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 AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
+18 -2
View File
@@ -1,4 +1,4 @@
import { createResource, createSignal, createMemo, For, Show } from "solid-js"; import { createResource, createSignal, createEffect, For, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAuth } from "../components/auth"; import { useAuth } from "../components/auth";
@@ -22,6 +22,13 @@ export default function Dashboard() {
const [events] = createResource(me, (user) => (user ? api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []) : [])); 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 [chartTiker, setChartTiker] = createSignal("BBCA");
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null)); 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]);
});
const ringkasan = () => { const ringkasan = () => {
const n = briefing()?.narasi?.trim(); const n = briefing()?.narasi?.trim();
@@ -124,7 +131,16 @@ export default function Dashboard() {
<CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription> <CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="mb-3 flex flex-wrap gap-2"> <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) => <For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) =>
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button> <Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
}</For> }</For>