Files
flowsight/backend/internal/scheduler/scheduler.go
T
asepharyana 9ea9a4291c
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
feat: scheduler tangkap company name dari top-changes (n=750) — gratis, isi yang kosong saja
2026-09-16 16:54:27 +07:00

520 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package scheduler wires the 30-min ingestion cycle (09:00-16:00 WIB market
// hours) plus per-routine cron dispatch into one in-process robfig/cron.
// Order per cycle: reference cache -> universe sweep -> market context ->
// per-watchlist depth -> incremental events -> quarterly freshness ->
// rule evaluation -> routine dispatch. Credit spend aborts past the cap.
package scheduler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
"flowsight/internal/alerts"
"flowsight/internal/config"
"flowsight/internal/routines"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// Scheduler owns ingestion + routine dispatch.
type Scheduler struct {
Cfg config.Config
DB *store.DB
Cache *store.Cache
Sectors *sectors.Client
Notifier *alerts.Notifier
Engine *routines.Engine
// Publish, when set, feeds routine activity into the SSE hub.
Publish func(channel, data string)
cron *cron.Cron
lastRun time.Time
ok bool
}
// New wires dependencies; the sectors OnSpend hook persists credit_ledger rows.
func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client) *Scheduler {
sch := &Scheduler{Cfg: cfg, DB: db, Cache: cache, Sectors: s,
Notifier: alerts.NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID, cfg.DiscordWebhookURL),
}
sch.Notifier.Targets = func(owner string) []alerts.Target {
dests, err := db.ListEnabledDestinations(owner)
if err != nil {
return nil
}
var out []alerts.Target
for _, d := range dests {
switch d.Kind {
case store.DestTelegram:
if d.BotToken != "" && d.ChatID != "" {
out = append(out, alerts.Target{TelegramToken: d.BotToken, TelegramChatID: d.ChatID})
}
case store.DestDiscord:
if d.WebhookURL != "" {
out = append(out, alerts.Target{DiscordURL: d.WebhookURL})
}
}
}
return out
}
sch.Engine = &routines.Engine{DB: db, Notifier: sch.Notifier, UserKey: cfg.DemoUserKey}
sch.Engine.Publish = func(channel, data string) {
if sch.Publish != nil {
sch.Publish(channel, data)
}
}
s.OnSpend(func(endpoint string, calls, credits int) {
db.AddSpend(time.Now().Format("2006-01-02"), endpoint, calls, credits)
})
return sch
}
// Start registers ingestion + routine crons (WIB = UTC+7) and begins ticking.
func (s *Scheduler) Start() {
c := cron.New(cron.WithLocation(wib()))
// 30-min ingestion, weekdays 09:00-16:00 WIB.
_, _ = c.AddFunc("*/30 9-16 * * 1-5", func() { s.RunCycle(context.Background()) })
// Routines: briefing 07:30 daily, countdowns 08:00, weekend Sat 09:00,
// watchtower R2..R4 every 30 min in market hours.
_, _ = c.AddFunc("30 7 * * *", func() { s.runType(context.Background(), routines.RBriefing) })
_, _ = c.AddFunc("*/30 9-16 * * 1-5", func() {
s.runType(context.Background(), routines.RRadar)
s.runType(context.Background(), routines.RReversal)
s.runType(context.Background(), routines.RInsider)
})
_, _ = c.AddFunc("0 8 * * *", func() { s.runType(context.Background(), routines.REarnings) })
_, _ = c.AddFunc("5 8 * * *", func() { s.runType(context.Background(), routines.RDividend) })
// Accuracy resolution on its own daily cadence (was piggybacked on RunCycle).
_, _ = c.AddFunc("15 6 * * *", func() {
if n, err := s.DB.ResolveDue(); err != nil {
log.Printf("scheduler: resolve: %v", err)
} else if n > 0 {
log.Printf("scheduler: resolved %d predictions", n)
}
})
// Snapshot retention: weekly prune of rows older than 180d (keeps newest
// per ticker/source for seeds and last-good fallback).
_, _ = c.AddFunc("30 6 * * 0", func() {
cutoff := time.Now().AddDate(0, 0, -180).Format("2006-01-02")
if n, err := s.DB.PruneSnapshots(cutoff); err != nil {
log.Printf("scheduler: prune: %v", err)
} else if n > 0 {
log.Printf("scheduler: pruned %d snapshots older than %s", n, cutoff)
}
})
_, _ = c.AddFunc("0 9 * * 6", func() { s.runType(context.Background(), routines.RWeekend) })
s.cron = c
c.Start()
s.ok = true
}
// Stop halts all crons.
func (s *Scheduler) Stop() {
if s.cron != nil {
s.cron.Stop()
}
s.ok = false
}
// Status reports scheduler health for /api/health.
func (s *Scheduler) Status() (lastCycle string, ok bool) {
if s.lastRun.IsZero() {
return "", s.ok
}
return s.lastRun.UTC().Format(time.RFC3339), s.ok
}
func wib() *time.Location {
return time.FixedZone("WIB", 7*3600)
}
// RunCycle executes one full ingestion cycle. Offline (no key) it returns
// early after marking scheduler health — seed data keeps the demo alive.
func (s *Scheduler) RunCycle(ctx context.Context) error {
if !s.Cfg.HasSectorsKey() {
s.lastRun = time.Now()
return nil
}
today := time.Now().Format("2006-01-02")
before := s.DB.CreditsToday(today)
guard := func() error {
if spent := s.DB.CreditsToday(today) - before; spent > s.Cfg.CreditCapPerCycle {
return fmt.Errorf("scheduler: credit cap %d exceeded (spent %d), aborting cycle",
s.Cfg.CreditCapPerCycle, spent)
}
return nil
}
if err := s.reference(ctx); err != nil {
log.Printf("scheduler: reference: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.universe(ctx); err != nil {
log.Printf("scheduler: universe: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.market(ctx); err != nil {
log.Printf("scheduler: market: %v", err)
}
if err := guard(); err != nil {
return err
}
for _, t := range s.watchlist() {
if err := s.tickerDepth(ctx, t); err != nil {
log.Printf("scheduler: depth %s: %v", t, err)
}
if err := guard(); err != nil {
return err
}
}
// 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)
}
if err := s.freshness(ctx); err != nil {
log.Printf("scheduler: freshness: %v", err)
}
s.evaluate(ctx)
s.lastRun = time.Now()
return nil
}
func (s *Scheduler) watchlist() []string {
return s.watchlistFor(s.Cfg.DemoUserKey)
}
// watchlistFor resolves one owner's watchlist: 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
}
return []string{"BBCA"}
}
// reference refreshes registry/taxonomy caches (24h TTL, fallback last good).
func (s *Scheduler) reference(ctx context.Context) error {
if raw := s.Cache.Get(ctx, "registry"); raw != "" {
return nil // fresh enough; TTL governs refresh
}
reg, err := s.Sectors.BrokersRegistry(ctx, "", "")
if err != nil {
if raw, _, lerr := s.DB.LatestSnapshot("IDX", "brokers-registry"); lerr == nil && raw != "" {
var cached []sectors.BrokerRegistryRow
if jerr := json.Unmarshal([]byte(raw), &cached); jerr == nil && len(cached) > 0 {
s.Cache.SetJSON(ctx, "registry", cached, 24*time.Hour)
return nil
}
}
return err
}
s.Cache.SetJSON(ctx, "registry", reg, 24*time.Hour)
if raw, err := json.Marshal(reg); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "brokers-registry", string(raw))
}
for _, kind := range []string{"subsectors", "industries", "subindustries", "tags"} {
if rows, err := s.Sectors.Taxonomy(ctx, kind); err == nil {
s.Cache.SetJSON(ctx, "tax:"+kind, rows, 24*time.Hour)
}
}
return nil
}
// universe sweeps close/ pages for the latest trading day 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
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
}
all = append(all, rows...)
offset += len(rows)
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. 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"}, "", 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
}
if mt, err := s.Sectors.MostTraded(ctx, "", "", "", 5); err == nil {
if raw, err := json.Marshal(mt); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "most-traded", string(raw))
}
}
if it, err := s.Sectors.IdxTotal(ctx, "", ""); err == nil {
if raw, err := json.Marshal(it); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "idx-total", string(raw))
}
}
if bt, err := s.Sectors.BrokersTop(ctx, "", "net", 20, "", ""); err == nil {
if raw, err := json.Marshal(map[string]any{"results": bt}); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "brokers-top", string(raw))
}
}
return nil
}
// tickerDepth pulls broker-summary/top + foreign-flow + daily per ticker.
// 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 {
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))
for _, b := range top.TopBuyers {
_ = s.DB.UpsertBrokerActivity(b.BrokerCode, ticker, end,
float64(b.BuyIDR), float64(b.SellIDR), float64(b.NetIDR), 0, 0, 0)
}
for _, x := range top.TopSellers {
_ = s.DB.UpsertBrokerActivity(x.BrokerCode, ticker, end,
float64(x.BuyIDR), float64(x.SellIDR), float64(x.NetIDR), 0, 0, 0)
}
}
} else {
return err
}
if ff, err := s.Sectors.ForeignFlow(ctx, ticker,
now.AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(ff); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
_ = s.DB.UpsertForeignFlow(ticker, d.Date, float64(d.NetForeignInflow))
}
}
}
if bars, err := s.Sectors.Daily(ctx, ticker,
time.Now().AddDate(0, 0, -30).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(bars); err == nil && len(bars) > 0 {
_ = s.DB.SaveSnapshot(ticker, end, "daily", string(raw))
}
}
return nil
}
// 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")
newsSince := s.DB.GetMeta("news_since")
if news, err := s.Sectors.News(ctx, "", newsSince, today, "", 20); err == nil {
if raw, err := json.Marshal(map[string]any{"results": news}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "news", string(raw))
}
s.DB.SetMeta("news_since", today)
}
filSince := s.DB.GetMeta("filings_since")
if fils, err := s.Sectors.Filings(ctx, "", "", "", filSince, today); err == nil {
if raw, err := json.Marshal(map[string]any{"results": fils}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "filings", string(raw))
}
s.DB.SetMeta("filings_since", today)
}
if susp, err := s.Sectors.Suspensions(ctx, "", newsSince, today); err == nil {
if raw, err := json.Marshal(map[string]any{"results": susp}); err == nil {
_ = s.DB.SaveSnapshot("IDX", today, "suspensions", string(raw))
}
}
return nil
}
// freshness polls quarterly-dates with since= (never a full sweep).
func (s *Scheduler) freshness(ctx context.Context) error {
since := s.DB.GetMeta("quarterly_since")
rows, err := s.Sectors.QuarterlyDatesSince(ctx, since, 30, 0)
if err != nil {
return err
}
if len(rows) > 0 {
if raw, err := json.Marshal(rows); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "quarterly-dates", string(raw))
}
s.DB.SetMeta("quarterly_since", time.Now().Format("2006-01-02"))
}
return nil
}
// evaluate runs rule evaluation for every user that owns alerts, each
// against that owner's own watchlist and destinations.
func (s *Scheduler) evaluate(ctx context.Context) {
for _, owner := range s.alertOwners() {
alertsList, err := s.DB.ListAlerts(owner)
if err != nil {
continue
}
if len(alertsList) == 0 {
continue
}
wl := s.watchlistFor(owner)
for _, a := range alertsList {
alerts.EvaluateFor(ctx, s.DB, s.Notifier, a.ID, owner, a.Rule, wl)
}
}
}
// runType executes all enabled routines of one type, per owning user: each
// run reads that user's watchlist and pushes to that user's destinations.
func (s *Scheduler) runType(ctx context.Context, typ string) {
for _, owner := range s.routineOwners() {
rows, err := s.DB.ListRoutines(owner)
if err != nil {
continue
}
for _, r := range rows {
if r.Type == typ && r.Enabled {
s.Engine.RunFor(ctx, owner, r)
}
}
}
}
// alertOwners lists users owning at least one alert.
func (s *Scheduler) alertOwners() []string {
return s.DB.DistinctUsers("alerts")
}
// routineOwners lists users owning at least one enabled routine.
func (s *Scheduler) routineOwners() []string {
owners := s.DB.DistinctUsers("routines")
if len(owners) == 0 {
return []string{s.Cfg.DemoUserKey}
}
return owners
}
// TriggerCycle runs one cycle synchronously (used by tests and the
// ?force=1 health probe); q carries no Sectors params.
var _ = url.Values{}