From d7eb0a3abfa171010f67a50d9be5371eeb5d5f05 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 16 Sep 2026 14:23:09 +0700 Subject: [PATCH] feat: company names in screener + universe enrichment - 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 --- backend/cmd/universe-sweep/main.go | 16 +++++++- backend/internal/api/screen.go | 15 ++++++- backend/internal/scheduler/scheduler.go | 16 +++++++- .../store/migrations/0010_universe_names.sql | 2 + backend/internal/store/rows.go | 40 ++++++++++++++++--- web/src/pages/Screener.tsx | 7 +++- 6 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 backend/internal/store/migrations/0010_universe_names.sql diff --git a/backend/cmd/universe-sweep/main.go b/backend/cmd/universe-sweep/main.go index c7d59dd..456a9bd 100644 --- a/backend/cmd/universe-sweep/main.go +++ b/backend/cmd/universe-sweep/main.go @@ -1,6 +1,7 @@ package main import ( + "strings" "context" "encoding/json" "fmt" @@ -54,9 +55,22 @@ func main() { } raw, _ := json.Marshal(all) _ = db.SaveSnapshot("IDX", date, "close", string(raw)) + // Best-effort company-name enrichment (one screener call) so the screener + // search can display "PT Bank Central Asia Tbk" instead of only "BBCA". + names := map[string]string{} + if rows, err := c.Screen(ctx, "", "", 1000, 0); err == nil { + for _, row := range rows { + if row.CompanyName != "" { + names[strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))] = row.CompanyName + } + } + } else { + log.Printf("name enrichment skipped: %v", err) + } 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}) + tk := strings.ToUpper(strings.TrimSuffix(r.Symbol, ".JK")) + uni = append(uni, store.UniverseRow{Symbol: tk, Close: r.Close, Date: r.Date, CompanyName: names[tk]}) } if err := db.SaveUniverse(uni); err != nil { log.Fatal(err) diff --git a/backend/internal/api/screen.go b/backend/internal/api/screen.go index f64b575..9254d48 100644 --- a/backend/internal/api/screen.go +++ b/backend/internal/api/screen.go @@ -45,10 +45,15 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) { } // Base universe: live screener when keyed, else stored watchlist. var universe []string + names := map[string]string{} if s.Cfg.HasSectorsKey() && (req.Where != "" || req.Q != "") { if rows, err := s.Sectors.Screen(r.Context(), req.Where, req.Q, limit*2, 0); err == nil { for _, row := range rows { - universe = append(universe, strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))) + tk := strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK")) + universe = append(universe, tk) + if row.CompanyName != "" { + names[tk] = row.CompanyName + } } } } @@ -61,10 +66,18 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go index 3e3ccdb..1b55cc5 100644 --- a/backend/internal/scheduler/scheduler.go +++ b/backend/internal/scheduler/scheduler.go @@ -6,6 +6,7 @@ package scheduler import ( + "strings" "context" "encoding/json" "fmt" @@ -283,10 +284,21 @@ func (s *Scheduler) universe(ctx context.Context) error { 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. + // IDX set instead of only tickers that have depth data yet. Company names + // ride along (one extra screener call — best effort, skipped on failure) + // so the screener search can show "PT Bank Central Asia Tbk" not "BBCA". + names := map[string]string{} + if rows, err := s.Sectors.Screen(ctx, "", "", 1000, 0); err == nil { + for _, row := range rows { + if row.CompanyName != "" { + names[strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))] = row.CompanyName + } + } + } 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}) + tk := strings.ToUpper(strings.TrimSuffix(r.Symbol, ".JK")) + uni = append(uni, store.UniverseRow{Symbol: tk, Close: r.Close, Date: r.Date, CompanyName: names[tk]}) } if err := s.DB.SaveUniverse(uni); err != nil { log.Printf("scheduler: universe save: %v", err) diff --git a/backend/internal/store/migrations/0010_universe_names.sql b/backend/internal/store/migrations/0010_universe_names.sql new file mode 100644 index 0000000..3f94816 --- /dev/null +++ b/backend/internal/store/migrations/0010_universe_names.sql @@ -0,0 +1,2 @@ +-- 0010_universe_names.sql: company names for screener display. +ALTER TABLE universe ADD COLUMN company_name TEXT NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/backend/internal/store/rows.go b/backend/internal/store/rows.go index 50cad7d..4850518 100644 --- a/backend/internal/store/rows.go +++ b/backend/internal/store/rows.go @@ -626,9 +626,10 @@ func (db *DB) TickersWithForeign() (map[string]bool, error) { // 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 + Symbol string + Close int64 + Date string + CompanyName string } func (db *DB) SaveUniverse(rows []UniverseRow) error { @@ -644,14 +645,43 @@ func (db *DB) SaveUniverse(rows []UniverseRow) error { // 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 { + 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() +} + // 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(?,?,?) diff --git a/web/src/pages/Screener.tsx b/web/src/pages/Screener.tsx index 75ba9bd..46a9689 100644 --- a/web/src/pages/Screener.tsx +++ b/web/src/pages/Screener.tsx @@ -80,7 +80,12 @@ function ScreenerInner() { return ( - {s.row.symbol} +
+ {s.row.symbol} + +

{s.row.name}

+
+