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:
@@ -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), ¬ices) == 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
|
||||
}())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user