feat: add Citations and WatchlistChat components, integrate with API

- Implemented Citations component to display citation data.
- Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality.
- Integrated API calls for watchlist management and chat interactions.
- Updated index.tsx to include new components in the main application layout.
- Added API client in lib/api.ts for structured API interactions.
- Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components.
- Introduced styles in tokens.css for consistent theming across the application.
- Configured TypeScript and Vite for project setup and development.
This commit is contained in:
asepharyana
2026-09-15 12:36:48 +07:00
parent 0db2cf28b3
commit 8c184ccae1
111 changed files with 12349 additions and 62 deletions
+248
View File
@@ -0,0 +1,248 @@
package alerts
import (
"context"
"encoding/json"
"time"
"flowsight/internal/model"
"flowsight/internal/store"
)
// Evaluate runs all 6 rules over stored snapshots for the watchlist and
// records + delivers hits. First-fire only per (rule, ticker, date): rows
// already present in alert_events for today are skipped. Delivery fans out to
// the alert owner's enabled destinations (server env is fallback). Prefer
// EvaluateFor; this wrapper keeps owner empty (server fallback only).
func Evaluate(ctx context.Context, db *store.DB, n *Notifier, alertID int64, ruleJSON string, tickers []string) []Finding {
owner := ""
_ = db.QueryRow(`SELECT user_key FROM alerts WHERE id=?`, alertID).Scan(&owner)
return EvaluateFor(ctx, db, n, alertID, owner, ruleJSON, tickers)
}
// EvaluateFor is Evaluate scoped to an explicit owner: history rows and
// delivery both use owner (no cross-user leakage via alert_id reuse).
func EvaluateFor(ctx context.Context, db *store.DB, n *Notifier, alertID int64, owner, ruleJSON string, tickers []string) []Finding {
today := time.Now().Format("2006-01-02")
var out []Finding
for _, t := range tickers {
cands := candidates(db, t, today)
for _, f := range cands {
if !MatchRule(ruleJSON, f) {
continue
}
if firedToday(db, f.Rule, t, today) {
continue
}
cites, _ := json.Marshal(f.Citations)
ctxJSON, _ := json.Marshal(f.Context)
if _, err := db.InsertAlertEvent(alertID, t, today, f.Message, string(ctxJSON), string(cites), owner); err != nil {
continue
}
_ = n.SendTo(ctx, owner, f, string(cites))
_ = db.MarkAlertFired(alertID)
out = append(out, f)
}
}
return out
}
// candidates runs every rule against one ticker's stored state. Every
// finding carries real citations (no "derived" placeholder).
func candidates(db *store.DB, ticker, today string) []Finding {
var out []Finding
brokerDate := today
if nets, err := db.NetBuySum5d(ticker); err == nil && len(nets) > 0 {
volMult := 0.0
volDate := today
if vols, dates, err := db.DailyVolumes(ticker, 21); err == nil && len(vols) >= 2 {
n := len(vols)
if a := avg(vols[:n-1]); a > 0 {
volMult = vols[n-1] / a
}
volDate = lastOf(dates)
}
if _, d, err := db.LatestSnapshot(ticker, "broker-summary-top"); err == nil {
brokerDate = d
}
if f, ok := Accumulation(ticker, nets, volMult); ok {
f.Citations = []model.Citation{
model.Cite("v2/broker-summary/"+ticker+"/top/", ticker, brokerDate),
model.Cite("v2/daily/"+ticker+"/", ticker, volDate),
}
out = append(out, f)
}
}
if dates, nets, err := db.ForeignLast6(ticker); err == nil && len(nets) == 6 {
if f, ok := ForeignReversal(ticker, nets); ok {
f.Citations = []model.Citation{
model.Cite("v2/foreign-flow/"+ticker+"/", ticker, dates[5]),
}
out = append(out, f)
}
}
// Insider spike: real 2x-volume path (latest buy vs 30d avg) plus the
// distinct-insider cluster path, both over stored filings.
if avg30 := db.FilingAvg30(ticker); avg30 > 0 {
if f, ok := InsiderSpike(ticker, db.LatestBuyVolume(ticker), avg30, db.DistinctInsiders7d(ticker)); ok {
f.Citations = []model.Citation{model.Cite("v2/filings/", ticker, today)}
out = append(out, f)
}
}
if vols, dates, err := db.DailyVolumes(ticker, 21); err == nil && len(vols) >= 2 {
n := len(vols)
if a := avg(vols[:n-1]); a > 0 {
if f, ok := UnusualVolume(ticker, lastOf(dates), vols[n-1]/a, isEarningsDate(db, ticker, lastOf(dates))); ok {
f.Citations = []model.Citation{
model.Cite("v2/daily/"+ticker+"/", ticker, lastOf(dates)),
}
out = append(out, f)
}
}
}
// Sector rotation from the stored sector-flow snapshot.
var flow struct {
Current map[string]float64 `json:"current"`
Previous map[string]float64 `json:"previous"`
}
if raw, d, err := db.LatestSnapshot("IDX", "sector-flow"); err == nil {
_ = json.Unmarshal([]byte(raw), &flow)
for sector, cur := range flow.Current {
if f, ok := SectorRotation(sector, flow.Previous[sector], cur); ok {
f.Citations = []model.Citation{model.Cite("v2/subsector/report/", sector, d)}
out = append(out, f)
}
}
}
// Suspension: fire only on notices dated today or later (new-notice diff).
if raw, d, err := db.LatestSnapshot(ticker, "suspensions"); err == nil && raw != "" {
var notices struct {
Results []struct {
Symbol string `json:"symbol"`
SuspensionDate string `json:"suspension_date"`
Reason string `json:"reason"`
} `json:"results"`
}
if json.Unmarshal([]byte(raw), &notices) == nil {
for _, sn := range notices.Results {
if sn.SuspensionDate >= today {
if f, ok := SuspensionWatch(ticker, sn.SuspensionDate, sn.Reason, true); ok {
f.Citations = []model.Citation{
model.Cite("v2/suspensions/", ticker, sn.SuspensionDate),
}
_ = d
out = append(out, f)
}
}
}
}
}
return out
}
// isEarningsDate reports whether date falls within 1d of a stored quarterly
// report date (rule-4 exclusion for unusual volume).
func isEarningsDate(db *store.DB, ticker, date string) bool {
var qdates []struct {
ReportDate string `json:"report_date"`
Date string `json:"date"`
}
raw, _, err := db.LatestSnapshot(ticker, "quarterly-dates")
if err != nil {
return false
}
if json.Unmarshal([]byte(raw), &qdates) != nil {
return false
}
for _, q := range qdates {
for _, d := range []string{q.ReportDate, q.Date} {
if len(d) >= 10 && len(date) >= 10 && daysBetween(d[:10], date[:10]) <= 1 {
return true
}
}
}
return false
}
func daysBetween(a, b string) int {
pa, ea := parseDay(a)
pb, eb := parseDay(b)
if ea != nil || eb != nil {
return 999
}
h := pa.Sub(pb).Hours() / 24
if h < 0 {
h = -h
}
return int(h + 0.5)
}
func parseDay(s string) (t time.Time, err error) {
return time.Parse("2006-01-02", s)
}
// firedToday enforces first-fire-only per (rule, ticker, date) for every
// rule: any event for the same rule+ticker+date suppresses re-fire.
func firedToday(db *store.DB, rule, ticker, today string) bool {
evts, err := db.AlertEventsSince(today, ticker, 50)
if err != nil {
return false
}
for _, e := range evts {
msg, _ := e["message"].(string)
if e["ticker"] == ticker && e["date"] == today && contains(msg, ruleHints(rule)) {
return true
}
}
return false
}
// ruleHints maps a rule id to the message fragment its findings carry.
func ruleHints(rule string) string {
switch rule {
case RuleAccumulation:
return "accumulation"
case RuleForeignRev:
return "reversal"
case RuleInsiderSpike:
return "insider"
case RuleUnusualVolume:
return "unusual volume"
case RuleRotation:
return "rotation"
case RuleSuspension:
return "suspend"
default:
return rule
}
}
func avg(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
s := 0.0
for _, x := range xs {
s += x
}
return s / float64(len(xs))
}
func lastOf(xs []string) string {
if len(xs) == 0 {
return ""
}
return xs[len(xs)-1]
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+101
View File
@@ -0,0 +1,101 @@
package alerts
import (
"context"
"testing"
"flowsight/internal/store"
)
func seedEvalDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(t.TempDir() + "/eval.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.SeedFromDir("../../tests/fixtures", "demo"); err != nil {
t.Fatal(err)
}
return db
}
// Accumulation fixture fires with real citations, then first-fire suppresses.
func TestEvaluateAccumulationFirstFire(t *testing.T) {
db := seedEvalDB(t)
n := NewNotifier("", "", "")
id, _ := db.CreateAlert("demo", "all", `{"all":true}`, nil)
got := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
found := false
for _, f := range got {
if f.Rule == RuleAccumulation {
found = true
if len(f.Citations) == 0 {
t.Fatal("accumulation finding has no citations")
}
}
}
if !found {
t.Fatalf("no accumulation finding in %v", got)
}
again := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
for _, f := range again {
if f.Rule == RuleAccumulation {
t.Fatal("accumulation re-fired same day")
}
}
}
// Rotation is reachable: sector-flow flip yields a cited finding.
func TestEvaluateRotationReachable(t *testing.T) {
db := seedEvalDB(t)
n := NewNotifier("", "", "")
id, _ := db.CreateAlert("demo", "all", `{"all":true}`, nil)
got := Evaluate(context.Background(), db, n, id, `{"all":true}`, []string{"BBCA"})
found := false
for _, f := range got {
if f.Rule == RuleRotation {
found = true
}
}
if !found {
t.Fatalf("no rotation finding in %v", ruleIDs(got))
}
}
func ruleIDs(fs []Finding) []string {
var out []string
for _, f := range fs {
out = append(out, f.Rule+":"+f.Ticker)
}
return out
}
// Per-owner delivery: finding fans out to the owner's destinations, and
// history rows carry the owner key (no cross-user leakage on alert_id reuse).
func TestEvaluateForOwnerDelivery(t *testing.T) {
db := seedEvalDB(t)
var sentTo string
var sent int
n := NewNotifier("", "", "")
n.Targets = func(owner string) []Target {
sentTo = owner
sent++
return nil // record-only; just observe routing
}
id, _ := db.CreateAlert("alice", "all", `{"all":true}`, nil)
got := EvaluateFor(context.Background(), db, n, id, "alice", `{"all":true}`, []string{"BBCA"})
if len(got) == 0 {
t.Fatal("want findings on seed")
}
if sent == 0 || sentTo != "alice" {
t.Fatalf("delivery not routed to owner: sent=%d to=%q", sent, sentTo)
}
evts, _ := db.AlertEventsSince("2000-01-01", "", 50, "alice")
if len(evts) == 0 {
t.Fatal("history rows must carry the owner key")
}
if evts2, _ := db.AlertEventsSince("2000-01-01", "", 50, "bob"); len(evts2) != 0 {
t.Fatal("bob must not see alice events")
}
}
+163
View File
@@ -0,0 +1,163 @@
package alerts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Target is one push destination resolved per user at send time.
type Target struct {
TelegramToken string
TelegramChatID string
DiscordURL string
}
// Notifier delivers alert cards to Telegram + Discord webhooks with context +
// citations. Missing credentials degrade to a no-op (logged, not fatal) so
// offline/demo runs never fail on delivery.
type Notifier struct {
TelegramToken string
TelegramChatID string
DiscordURL string
http *http.Client
// Targets, when set, resolves per-owner push destinations. It is consulted
// on every Send so destination CRUD takes effect immediately.
Targets func(owner string) []Target
}
// NewNotifier builds a notifier; empty creds mean dry-run mode.
func NewNotifier(tgToken, tgChat, discordURL string) *Notifier {
return &Notifier{TelegramToken: tgToken, TelegramChatID: tgChat,
DiscordURL: discordURL, http: &http.Client{Timeout: 15 * time.Second}}
}
// DryRun reports whether no server-level channel is configured.
func (n *Notifier) DryRun() bool {
return n.TelegramToken == "" && n.DiscordURL == ""
}
// DryRunFor reports whether an owner has no push target anywhere: neither
// the owner's enabled destinations nor the server fallback.
func (n *Notifier) DryRunFor(owner string) bool {
return len(n.targetsFor(owner)) == 0
}
// targetsFor resolves push targets: per-owner destinations first, then the
// server-level env fallback. Owner "" means server fallback only.
func (n *Notifier) targetsFor(owner string) []Target {
var out []Target
if n.Targets != nil && owner != "" {
out = append(out, n.Targets(owner)...)
}
if n.TelegramToken != "" && n.TelegramChatID != "" {
out = append(out, Target{TelegramToken: n.TelegramToken, TelegramChatID: n.TelegramChatID})
}
if n.DiscordURL != "" {
out = append(out, Target{DiscordURL: n.DiscordURL})
}
return out
}
// Card is the rendered alert text shared by both channels.
func Card(f Finding, cites string) string {
var b strings.Builder
fmt.Fprintf(&b, "*%s* — %s\n%s", f.Ticker, f.Rule, f.Message)
if len(f.Context) > 0 {
raw, _ := json.Marshal(f.Context)
fmt.Fprintf(&b, "\n`%s`", string(raw))
}
if cites != "" {
fmt.Fprintf(&b, "\nCitations: %s", cites)
}
fmt.Fprintf(&b, "\nDetail: /report/%s", f.Ticker)
return b.String()
}
// Send delivers one finding to all configured channels (server fallback).
// Prefer SendTo so delivery fans out to the finding owner's destinations.
func (n *Notifier) Send(ctx context.Context, f Finding, cites string) error {
return n.SendTo(ctx, "", f, cites)
}
// SendTo delivers one finding to the owner's enabled destinations plus the
// server fallback. With no targets anywhere it is record-only (nil).
func (n *Notifier) SendTo(ctx context.Context, owner string, f Finding, cites string) error {
targets := n.targetsFor(owner)
if len(targets) == 0 {
return nil // recorded in alert_events regardless
}
if cites == "" && len(f.Citations) > 0 {
cc, _ := json.Marshal(f.Citations)
cites = string(cc)
}
text := Card(f, cites)
var firstErr error
for _, t := range targets {
if t.TelegramToken != "" && t.TelegramChatID != "" {
if err := sendTelegram(ctx, n.http, t.TelegramToken, t.TelegramChatID, text); err != nil && firstErr == nil {
firstErr = err
}
}
if t.DiscordURL != "" {
if err := sendDiscord(ctx, n.http, t.DiscordURL, text); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func (n *Notifier) telegram(ctx context.Context, text string) error {
return sendTelegram(ctx, n.http, n.TelegramToken, n.TelegramChatID, text)
}
func sendTelegram(ctx context.Context, client *http.Client, token, chatID, text string) error {
u := "https://api.telegram.org/bot" + token + "/sendMessage"
body, _ := json.Marshal(map[string]any{
"chat_id": chatID, "text": text, "parse_mode": "Markdown",
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alerts: telegram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("alerts: telegram HTTP %d", resp.StatusCode)
}
return nil
}
func (n *Notifier) discord(ctx context.Context, text string) error {
return sendDiscord(ctx, n.http, n.DiscordURL, text)
}
func sendDiscord(ctx context.Context, client *http.Client, webhookURL, text string) error {
if len(text) > 1900 {
text = text[:1900] + "…"
}
body, _ := json.Marshal(map[string]any{"content": text})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alerts: discord: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("alerts: discord HTTP %d", resp.StatusCode)
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
// Package alerts evaluates 6 detection rules (docs/PLAN.md §11) over stored
// snapshots. Rules are pure functions over typed inputs so fixture tests can
// prove each fires (or stays silent) deterministically.
package alerts
import (
"strings"
"flowsight/internal/model"
)
// Rule IDs for the 6 v1 detectors.
const (
RuleAccumulation = "accumulation"
RuleForeignRev = "foreign-reversal"
RuleInsiderSpike = "insider-spike"
RuleUnusualVolume = "unusual-volume"
RuleRotation = "sector-rotation"
RuleSuspension = "suspension-watch"
)
// Finding is one rule hit with human text + evidence + real citations.
type Finding struct {
Rule string
Ticker string
Message string
Context map[string]any
Citations []model.Citation
}
// Accumulation fires on >=3 brokers net-buy 5d + volume > 1.5x 20d avg.
func Accumulation(ticker string, netByBroker map[string]float64, volMult float64) (Finding, bool) {
n := 0
sum := 0.0
for _, v := range netByBroker {
if v > 0 {
n++
sum += v
}
}
if n >= 3 && volMult > 1.5 {
return Finding{Rule: RuleAccumulation, Ticker: ticker,
Message: ticker + " accumulation: " + itoa(n) + " brokers net-buy, volume spike",
Context: map[string]any{"brokers": n, "net_sum": sum, "vol_mult": volMult}}, true
}
return Finding{}, false
}
// ForeignReversal fires on 5d cumulative outflow then 1d inflow with the last
// day magnitude > 2x trailing 5d daily average (or the mirror).
func ForeignReversal(ticker string, last6 []float64) (Finding, bool) {
if len(last6) < 6 {
return Finding{}, false
}
prev5, last := last6[:5], last6[5]
sum5 := 0.0
absAvg := 0.0
for _, v := range prev5 {
sum5 += v
absAvg += abs(v)
}
absAvg /= 5
if absAvg == 0 {
return Finding{}, false
}
if sum5 < 0 && last > 0 && last > 2*absAvg {
return Finding{Rule: RuleForeignRev, Ticker: ticker,
Message: ticker + " foreign reversal: outflow flipped to inflow",
Context: map[string]any{"sum5": sum5, "last": last}}, true
}
if sum5 > 0 && last < 0 && -last > 2*absAvg {
return Finding{Rule: RuleForeignRev, Ticker: ticker,
Message: ticker + " foreign reversal: inflow flipped to outflow",
Context: map[string]any{"sum5": sum5, "last": last}}, true
}
return Finding{}, false
}
// InsiderSpike fires on director/major buy volume > 2x 30d avg, or >=3
// distinct insiders buying in 7d.
func InsiderSpike(ticker string, buyVol, avg30 float64, distinct7d int) (Finding, bool) {
if avg30 > 0 && buyVol > 2*avg30 {
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
Message: ticker + " insider spike: buy volume above 2x 30d avg",
Context: map[string]any{"volume": buyVol, "avg30": avg30, "mult": buyVol / avg30}}, true
}
if distinct7d >= 3 {
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
Message: ticker + " insider cluster: 3+ insiders buying in 7d",
Context: map[string]any{"distinct": distinct7d}}, true
}
return Finding{}, false
}
// UnusualVolume fires on >3x 20d avg when the date is not an earnings date.
func UnusualVolume(ticker, date string, mult float64, isEarningsDate bool) (Finding, bool) {
if mult > 3 && !isEarningsDate {
return Finding{Rule: RuleUnusualVolume, Ticker: ticker,
Message: ticker + " unusual volume on " + date,
Context: map[string]any{"mult": mult, "date": date}}, true
}
return Finding{}, false
}
// SectorRotation fires when a subsector net flow flips sign week-over-week.
func SectorRotation(sector string, prev, cur float64) (Finding, bool) {
if (prev < 0 && cur > 0) || (prev > 0 && cur < 0) {
dir := "inflow"
if cur < 0 {
dir = "outflow"
}
return Finding{Rule: RuleRotation, Ticker: sector,
Message: "rotation: " + sector + " flipped to " + dir,
Context: map[string]any{"prev": prev, "cur": cur}}, true
}
return Finding{}, false
}
// SuspensionWatch fires on any new suspension notice for a watchlist ticker.
func SuspensionWatch(ticker, date, reason string, onWatchlist bool) (Finding, bool) {
if reason == "" && date == "" {
return Finding{}, false
}
if onWatchlist {
return Finding{Rule: RuleSuspension, Ticker: ticker,
Message: ticker + " suspended: " + reason,
Context: map[string]any{"date": date, "reason": reason}}, true
}
return Finding{}, false
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
// MatchRule reports whether a user rule JSON targets a finding (simple
// substring match on rule id or ticker; empty rule matches all).
func MatchRule(ruleJSON string, f Finding) bool {
if ruleJSON == "" || ruleJSON == "{}" {
return true
}
r := strings.ToLower(ruleJSON)
return strings.Contains(r, strings.ToLower(f.Rule)) ||
strings.Contains(r, strings.ToLower(f.Ticker)) ||
strings.Contains(r, `"all"`)
}
+76
View File
@@ -0,0 +1,76 @@
package alerts
import "testing"
// Accumulation fixture: 3 brokers net-buy + 1.6x volume fires.
func TestAccumulationFires(t *testing.T) {
nets := map[string]float64{"MG": 5e11, "AK": 4e11, "CC": 3e11, "BK": -1e11}
f, ok := Accumulation("BBCA", nets, 1.6)
if !ok {
t.Fatal("want accumulation fire")
}
if f.Rule != RuleAccumulation {
t.Fatalf("rule = %s", f.Rule)
}
}
func TestAccumulationSilent(t *testing.T) {
nets := map[string]float64{"MG": 5e11, "BK": -4e11}
if _, ok := Accumulation("BBCA", nets, 1.1); ok {
t.Fatal("want silence: only 1 net-buy broker, low volume")
}
}
// Foreign reversal: 5d outflow then strong inflow fires.
func TestForeignReversal(t *testing.T) {
last6 := []float64{-1e11, -1e11, -1e11, -1e11, -1e11, 3.4e11}
if _, ok := ForeignReversal("BBCA", last6); !ok {
t.Fatal("want reversal fire")
}
if _, ok := ForeignReversal("BBCA", []float64{1e11, 1e11}); ok {
t.Fatal("want silence on short series")
}
}
// Insider spike both paths.
func TestInsiderSpike(t *testing.T) {
if _, ok := InsiderSpike("BBCA", 3e6, 1e6, 1); !ok {
t.Fatal("want volume-path fire")
}
if _, ok := InsiderSpike("BBCA", 0, 0, 3); !ok {
t.Fatal("want cluster-path fire")
}
if _, ok := InsiderSpike("BBCA", 1e6, 1e6, 1); ok {
t.Fatal("want silence")
}
}
// Unusual volume suppressed on earnings dates.
func TestUnusualVolume(t *testing.T) {
if _, ok := UnusualVolume("BBCA", "2026-09-11", 3.2, false); !ok {
t.Fatal("want fire")
}
if _, ok := UnusualVolume("BBCA", "2026-09-11", 3.2, true); ok {
t.Fatal("want silence on earnings date")
}
}
// Rotation sign flip.
func TestRotation(t *testing.T) {
if _, ok := SectorRotation("consumer", -3e8, 1.1e9); !ok {
t.Fatal("want rotation fire")
}
if _, ok := SectorRotation("banks", 1e9, 2e9); ok {
t.Fatal("want silence without flip")
}
}
// Suspension watch: watchlist fires, others silent.
func TestSuspension(t *testing.T) {
if _, ok := SuspensionWatch("TLKM", "2026-09-10", "volatilitas", true); !ok {
t.Fatal("want fire on watchlist")
}
if _, ok := SuspensionWatch("TLKM", "2026-09-10", "volatilitas", false); ok {
t.Fatal("want silence off-watchlist")
}
}