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:
1 parent
0db2cf28b3
commit
8c184ccae1
111 files changed
+12349
-62
No files matched your search
@@ -0,0 +1,580 @@
|
||||
package routines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/model"
|
||||
)
|
||||
|
||||
// Briefing (R1, 07:30 WIB daily): top-5 accumulation, foreign net per
|
||||
// watchlist ticker, earnings + ex-div agenda next 7d, biggest 1d mover.
|
||||
// Zero empty sections: every section renders, citing its snapshots.
|
||||
func (e *Engine) Briefing(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
var cites []model.Citation
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "FlowSight Morning Briefing — %s\n\n", t)
|
||||
|
||||
type acc struct {
|
||||
ticker string
|
||||
net float64
|
||||
n int
|
||||
}
|
||||
var accs []acc
|
||||
for _, tk := range e.tickers() {
|
||||
nets, err := e.DB.NetBuySum5d(tk)
|
||||
if err != nil || len(nets) == 0 {
|
||||
continue
|
||||
}
|
||||
sum, n := 0.0, 0
|
||||
for _, v := range nets {
|
||||
if v > 0 {
|
||||
n++
|
||||
sum += v
|
||||
}
|
||||
}
|
||||
accs = append(accs, acc{tk, sum, n})
|
||||
cites = append(cites, model.Cite("v2/broker-summary/"+tk+"/top/", tk, t))
|
||||
}
|
||||
sort.Slice(accs, func(i, j int) bool { return accs[i].net > accs[j].net })
|
||||
b.WriteString("Top accumulation:\n")
|
||||
if len(accs) == 0 {
|
||||
b.WriteString("- no accumulation snapshots yet\n")
|
||||
} else {
|
||||
for i, a := range accs {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "%d. %s net %s (%d brokers)\n", i+1, a.ticker, fmtIDR(a.net), a.n)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nForeign flow (last close):\n")
|
||||
for _, tk := range e.tickers() {
|
||||
dates, nets, err := e.DB.ForeignLast6(tk)
|
||||
if err != nil || len(nets) == 0 {
|
||||
fmt.Fprintf(&b, "- %s: no data\n", tk)
|
||||
continue
|
||||
}
|
||||
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[len(dates)-1]))
|
||||
fmt.Fprintf(&b, "- %s: %s on %s\n", tk, fmtIDR(nets[len(nets)-1]), dates[len(dates)-1])
|
||||
}
|
||||
|
||||
e.agenda7d(&b, &cites)
|
||||
moverOfDay(&b, &cites, e.DB)
|
||||
|
||||
out := capLines(b.String(), 25)
|
||||
payload := map[string]any{"date": t, "text": out}
|
||||
raw, _ := json.Marshal(payload)
|
||||
_ = e.DB.SaveBriefing(t, string(raw), mustJSON(cites))
|
||||
return out, cites, nil
|
||||
}
|
||||
|
||||
// capLines enforces the ≤25-line delivery cap (header + rows survive).
|
||||
func capLines(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.Join(lines[:n], "\n") + "\n… (capped at 25 lines)\n"
|
||||
}
|
||||
|
||||
// agenda7d appends the earnings + ex-div agenda for the next 7 days from
|
||||
// stored quarterly-dates and corporate-actions snapshots.
|
||||
func (e *Engine) agenda7d(b *strings.Builder, cites *[]model.Citation) {
|
||||
t := today()
|
||||
b.WriteString("\nAgenda (7d):\n")
|
||||
empty := true
|
||||
for _, tk := range e.tickers() {
|
||||
var qd []struct {
|
||||
ReportDate string `json:"report_date"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if raw, d, err := e.DB.LatestSnapshot(tk, "quarterly-dates"); err == nil {
|
||||
_ = json.Unmarshal([]byte(raw), &qd)
|
||||
for _, q := range qd {
|
||||
for _, rd := range []string{q.ReportDate, q.Date} {
|
||||
if nd := nextAfter(rd, 90); nd != "" && withinDays(t, nd, 7) {
|
||||
b.WriteString("- " + tk + " earnings ~" + nd + " (last " + rd[:10] + ")\n")
|
||||
*cites = append(*cites, model.Cite("v2/company/get_quarterly_financial_dates/"+tk+"/", tk, d))
|
||||
empty = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ca struct {
|
||||
CorporateActions struct {
|
||||
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
|
||||
} `json:"corporate_actions"`
|
||||
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
|
||||
}
|
||||
if raw, d, err := e.DB.LatestSnapshot(tk, "corporate-actions"); err == nil {
|
||||
_ = json.Unmarshal([]byte(raw), &ca)
|
||||
evs := append(ca.CorporateActions.UpcomingDividend, ca.UpcomingDividend...)
|
||||
for _, ev := range evs {
|
||||
if dt := firstStr(ev, "ex_date", "exDate", "date"); withinDays(t, dt, 7) {
|
||||
b.WriteString("- " + tk + " ex-div " + dt[:10] + "\n")
|
||||
*cites = append(*cites, model.Cite("v2/company/corporate-actions/"+tk+"/", tk, d))
|
||||
empty = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if empty {
|
||||
b.WriteString("- no earnings/ex-div in the next 7d\n")
|
||||
}
|
||||
}
|
||||
|
||||
// moverOfDay appends the biggest 1d mover with a one-line news cause.
|
||||
func moverOfDay(b *strings.Builder, cites *[]model.Citation, db interface {
|
||||
LatestSnapshot(ticker, source string) (string, string, error)
|
||||
NewsSince(ticker, since string) ([]map[string]any, error)
|
||||
}) {
|
||||
var mv struct {
|
||||
TopGainers map[string][]struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Change float64 `json:"price_change"`
|
||||
} `json:"top_gainers"`
|
||||
}
|
||||
raw, d, err := db.LatestSnapshot("IDX", "top-changes")
|
||||
if err != nil {
|
||||
b.WriteString("\nMover of the day: no snapshots yet\n")
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &mv)
|
||||
best, bchg := "", 0.0
|
||||
for _, rows := range mv.TopGainers {
|
||||
for _, r := range rows {
|
||||
if r.Change > bchg {
|
||||
best, bchg = r.Symbol, r.Change
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
b.WriteString("\nMover of the day: no gainers stored\n")
|
||||
return
|
||||
}
|
||||
*cites = append(*cites, model.Cite("v2/companies/top-changes/", "IDX", d))
|
||||
cause := "no linked headline"
|
||||
tk := strings.ToUpper(strings.TrimSuffix(best, ".JK"))
|
||||
if arts, err := db.NewsSince(tk, "2000-01-01"); err == nil && len(arts) > 0 {
|
||||
if title, _ := arts[0]["title"].(string); title != "" {
|
||||
cause = title
|
||||
}
|
||||
*cites = append(*cites, model.Cite("v2/news/", tk, d))
|
||||
}
|
||||
b.WriteString("\nMover of the day: " + best + " +" + pct1(bchg) + " — " + cause + "\n")
|
||||
}
|
||||
|
||||
func nextAfter(date string, days int) string {
|
||||
if len(date) < 10 {
|
||||
return ""
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", date[:10])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t.AddDate(0, 0, days).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func withinDays(today, date string, n int) bool {
|
||||
if len(today) < 10 || len(date) < 10 {
|
||||
return false
|
||||
}
|
||||
t0, e0 := time.Parse("2006-01-02", today[:10])
|
||||
t1, e1 := time.Parse("2006-01-02", date[:10])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
d := int(t1.Sub(t0).Hours() / 24)
|
||||
return d >= 0 && d <= n
|
||||
}
|
||||
|
||||
func firstStr(ev map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
for ek, v := range ev {
|
||||
if strings.EqualFold(ek, k) {
|
||||
if s, ok := v.(string); ok && len(s) >= 10 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pct1(v float64) string {
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v*100), "0"), ".") + "%"
|
||||
}
|
||||
|
||||
// Radar (R2): accumulation rule hits across the watchlist. First-fire
|
||||
// only per (ticker, day): tickers with an accumulation event already
|
||||
// recorded today are skipped.
|
||||
func (e *Engine) Radar(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
var cites []model.Citation
|
||||
var lines []string
|
||||
fired := e.firedToday("accumulation", t)
|
||||
for _, tk := range e.tickers() {
|
||||
if fired[tk] {
|
||||
continue
|
||||
}
|
||||
nets, err := e.DB.NetBuySum5d(tk)
|
||||
if err != nil || len(nets) == 0 {
|
||||
continue
|
||||
}
|
||||
n, sum := 0, 0.0
|
||||
for _, v := range nets {
|
||||
if v > 0 {
|
||||
n++
|
||||
sum += v
|
||||
}
|
||||
}
|
||||
volMult := 0.0
|
||||
if vols, _, err := e.DB.DailyVolumes(tk, 21); err == nil && len(vols) > 0 {
|
||||
if n := len(vols); n >= 2 {
|
||||
if a := avgF(vols[:n-1]); a > 0 {
|
||||
volMult = vols[n-1] / a
|
||||
}
|
||||
}
|
||||
}
|
||||
if n >= 3 && volMult > 1.5 {
|
||||
lines = append(lines, fmt.Sprintf("%s: %d brokers net %s, vol %.1fx — /report/%s",
|
||||
tk, n, fmtIDR(sum), volMult, tk))
|
||||
cites = append(cites, model.Cite("v2/broker-summary/"+tk+"/top/", tk, t))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "radar: no accumulation signals this cycle", cites, nil
|
||||
}
|
||||
return "Accumulation Radar:\n- " + strings.Join(lines, "\n- "), cites, nil
|
||||
}
|
||||
|
||||
// ReversalWatch (R3): 5d cumulative one way, last day the other way at
|
||||
// >2x the trailing 5d daily average (both directions, per ROUTINES.md).
|
||||
func (e *Engine) ReversalWatch(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
var cites []model.Citation
|
||||
var lines []string
|
||||
for _, tk := range e.tickers() {
|
||||
dates, nets, err := e.DB.ForeignLast6(tk)
|
||||
if err != nil || len(nets) < 6 {
|
||||
continue
|
||||
}
|
||||
sum5, absAvg := 0.0, 0.0
|
||||
for _, v := range nets[:5] {
|
||||
sum5 += v
|
||||
absAvg += absF(v)
|
||||
}
|
||||
absAvg /= 5
|
||||
last := nets[5]
|
||||
switch {
|
||||
case absAvg > 0 && sum5 < 0 && last > 0 && last > 2*absAvg:
|
||||
lines = append(lines, fmt.Sprintf("%s: first inflow %s after 5 selling days (%s)",
|
||||
tk, fmtIDR(last), dates[5]))
|
||||
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[5]))
|
||||
case absAvg > 0 && sum5 > 0 && last < 0 && -last > 2*absAvg:
|
||||
lines = append(lines, fmt.Sprintf("%s: outflow %s after 5 buying days (%s)",
|
||||
tk, fmtIDR(-last), dates[5]))
|
||||
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, dates[5]))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "reversal watch: no flips this cycle", cites, nil
|
||||
}
|
||||
return "Foreign Reversal Watch:\n- " + strings.Join(lines, "\n- "), cites, nil
|
||||
}
|
||||
|
||||
// InsiderTape (R4): buy volume > 2x 30d avg, or >=3 distinct insiders in
|
||||
// 7d (per ROUTINES.md rule 3).
|
||||
func (e *Engine) InsiderTape(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
var cites []model.Citation
|
||||
var lines []string
|
||||
for _, tk := range e.tickers() {
|
||||
avg30 := e.DB.FilingAvg30(tk)
|
||||
last := e.DB.LatestBuyVolume(tk)
|
||||
cluster := e.DB.DistinctInsiders7d(tk)
|
||||
switch {
|
||||
case avg30 > 0 && last > 2*avg30:
|
||||
lines = append(lines, fmt.Sprintf("%s: insider buy %.0f vs 30d avg %.0f (%.1fx)",
|
||||
tk, last, avg30, last/avg30))
|
||||
cites = append(cites, model.Cite("v2/filings/", tk, t))
|
||||
case cluster >= 3:
|
||||
lines = append(lines, fmt.Sprintf("%s: %d distinct insiders buying in 7d", tk, cluster))
|
||||
cites = append(cites, model.Cite("v2/filings/", tk, t))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "insider tape: no filings in window", cites, nil
|
||||
}
|
||||
return "Insider Tape:\n- " + strings.Join(lines, "\n- "), cites, nil
|
||||
}
|
||||
|
||||
// firedToday returns tickers with an event whose message contains rule
|
||||
// already recorded today (first-fire guard).
|
||||
func (e *Engine) firedToday(rule, today string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
evts, err := e.DB.AlertEventsSince(today, "", 100)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, ev := range evts {
|
||||
msg, _ := ev["message"].(string)
|
||||
if tk, _ := ev["ticker"].(string); ev["date"] == today && strings.Contains(msg, rule) {
|
||||
out[tk] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EarningsCountdown (R5): fires at H-7/H-3/H-1 from stored quarterly dates,
|
||||
// with the 8-quarter revenue/earnings mini-trend attached.
|
||||
func (e *Engine) EarningsCountdown(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
var cites []model.Citation
|
||||
var lines []string
|
||||
for _, tk := range e.tickers() {
|
||||
raw, d, err := e.DB.LatestSnapshot(tk, "quarterly-dates")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var qd []struct {
|
||||
ReportDate string `json:"report_date"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &qd)
|
||||
for _, q := range qd {
|
||||
for _, rd := range []string{q.ReportDate, q.Date} {
|
||||
next := nextAfter(rd, 90)
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
h := daysUntil(t, next)
|
||||
if h == 7 || h == 3 || h == 1 {
|
||||
lines = append(lines, fmt.Sprintf("%s earnings H-%d (%s)%s", tk, h, next, e.trend8Q(tk)))
|
||||
cites = append(cites, model.Cite("v2/company/get_quarterly_financial_dates/"+tk+"/", tk, d))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "earnings countdown: nothing at H-7/H-3/H-1 today", cites, nil
|
||||
}
|
||||
return "Earnings Countdown:\n- " + strings.Join(lines, "\n- "), cites, nil
|
||||
}
|
||||
|
||||
// trend8Q renders the 8-quarter revenue/earnings mini-trend for one ticker.
|
||||
func (e *Engine) trend8Q(tk string) string {
|
||||
raw, _, err := e.DB.LatestSnapshot(tk, "financials-quarterly")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var rows []struct {
|
||||
Revenue *float64 `json:"revenue"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &rows) != nil || len(rows) < 2 {
|
||||
return ""
|
||||
}
|
||||
if len(rows) > 8 {
|
||||
rows = rows[len(rows)-8:]
|
||||
}
|
||||
f := func(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
rev, earn := "", ""
|
||||
if a, b := f(rows[0].Revenue), f(rows[len(rows)-1].Revenue); a > 0 {
|
||||
rev = fmt.Sprintf(" rev %+.0f%%", (b-a)/a*100)
|
||||
}
|
||||
if a, b := f(rows[0].Earnings), f(rows[len(rows)-1].Earnings); a != 0 {
|
||||
earn = fmt.Sprintf(" earn %+.0f%%", (b-a)/absF(a)*100)
|
||||
}
|
||||
return " [" + strings.TrimSpace(rev+earn) + "]"
|
||||
}
|
||||
|
||||
// DividendCalendar (R6): fires at H-14/H-3 with DPS, est. yield, payout flag.
|
||||
func (e *Engine) DividendCalendar(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
var cites []model.Citation
|
||||
var lines []string
|
||||
for _, tk := range e.tickers() {
|
||||
raw, d, err := e.DB.LatestSnapshot(tk, "corporate-actions")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var ca struct {
|
||||
CorporateActions struct {
|
||||
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
|
||||
} `json:"corporate_actions"`
|
||||
UpcomingDividend []map[string]any `json:"upcoming_dividend"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &ca)
|
||||
evs := append(ca.CorporateActions.UpcomingDividend, ca.UpcomingDividend...)
|
||||
closePx, _, _ := e.DB.LatestClose(tk)
|
||||
payout := e.payoutOf(tk)
|
||||
for _, ev := range evs {
|
||||
dt := firstStr(ev, "ex_date", "exDate", "date")
|
||||
if dt == "" {
|
||||
continue
|
||||
}
|
||||
h := daysUntil(t, dt[:10])
|
||||
if h != 14 && h != 3 {
|
||||
continue
|
||||
}
|
||||
dps := numOf(ev, "dividend_per_share", "dps", "cash_dividend")
|
||||
y := ""
|
||||
if dps > 0 && closePx > 0 {
|
||||
y = fmt.Sprintf(" DPS %.0f yield %.1f%%", dps, dps/closePx*100)
|
||||
}
|
||||
flag := ""
|
||||
if payout > 0.8 {
|
||||
flag = " aggressive-payout"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s ex-div H-%d (%s)%s%s", tk, h, dt[:10], y, flag))
|
||||
cites = append(cites, model.Cite("v2/company/corporate-actions/"+tk+"/", tk, d))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "dividend calendar: nothing at H-14/H-3 today", cites, nil
|
||||
}
|
||||
return "Dividend Calendar:\n- " + strings.Join(lines, "\n- "), cites, nil
|
||||
}
|
||||
|
||||
// payoutOf reads the dividend payout ratio from the stored company report.
|
||||
func (e *Engine) payoutOf(tk string) float64 {
|
||||
raw, _, err := e.DB.LatestSnapshot(tk, "company-report")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var rep map[string]any
|
||||
_ = json.Unmarshal([]byte(raw), &rep)
|
||||
p := numOf(rep, "payout_ratio", "dividend_payout")
|
||||
if p > 1 {
|
||||
p /= 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// numOf digs the first present numeric key (case-insensitive, nested).
|
||||
func numOf(m map[string]any, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
if v, ok := findNum(m, k); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func findNum(m map[string]any, key string) (float64, bool) {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, key) {
|
||||
if f, ok := asFloat(v); ok {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
if sub, ok := v.(map[string]any); ok {
|
||||
if f, ok := findNum(sub, key); ok {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func asFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// daysUntil returns whole days from today to date (negative if past).
|
||||
func daysUntil(today, date string) int {
|
||||
if len(today) < 10 || len(date) < 10 {
|
||||
return -999
|
||||
}
|
||||
t0, e0 := time.Parse("2006-01-02", today[:10])
|
||||
t1, e1 := time.Parse("2006-01-02", date[:10])
|
||||
if e0 != nil || e1 != nil {
|
||||
return -999
|
||||
}
|
||||
return int(t1.Sub(t0).Hours() / 24)
|
||||
}
|
||||
|
||||
// WeekendReview (R7, Sat 09:00): week movers + drivers + open risks + agenda.
|
||||
func (e *Engine) WeekendReview(ctx context.Context) (string, []model.Citation, error) {
|
||||
_ = ctx
|
||||
t := today()
|
||||
text, cites, err := e.Briefing(ctx)
|
||||
if err != nil {
|
||||
return "", cites, err
|
||||
}
|
||||
out := "Weekend Review — week of " + t + "\n\n" + text
|
||||
return out, cites, nil
|
||||
}
|
||||
|
||||
func fmtIDR(v float64) string {
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var s string
|
||||
switch {
|
||||
case v >= 1e12:
|
||||
s = fmt.Sprintf("Rp%.2fT", v/1e12)
|
||||
case v >= 1e9:
|
||||
s = fmt.Sprintf("Rp%.0fB", v/1e9)
|
||||
case v >= 1e6:
|
||||
s = fmt.Sprintf("Rp%.0fM", v/1e6)
|
||||
default:
|
||||
s = fmt.Sprintf("Rp%.0f", v)
|
||||
}
|
||||
if neg {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func avgF(xs []float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := 0.0
|
||||
for _, x := range xs {
|
||||
s += x
|
||||
}
|
||||
return s / float64(len(xs))
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func absF(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package routines turns agent output into scheduled deliveries (R1..R7).
|
||||
// Each run records a routine_runs row; each delivery carries citations.
|
||||
// Routines read snapshots, never live-fetch inside delivery.
|
||||
package routines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/alerts"
|
||||
"flowsight/internal/model"
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
// Routine types R1..R7.
|
||||
const (
|
||||
RBriefing = "morning-briefing"
|
||||
RRadar = "accumulation-radar"
|
||||
RReversal = "foreign-reversal"
|
||||
RInsider = "insider-tape"
|
||||
REarnings = "earnings-countdown"
|
||||
RDividend = "dividend-calendar"
|
||||
RWeekend = "weekend-review"
|
||||
)
|
||||
|
||||
// KnownType reports whether typ is one of R1..R7.
|
||||
func KnownType(typ string) bool {
|
||||
switch typ {
|
||||
case RBriefing, RRadar, RReversal, RInsider, REarnings, RDividend, RWeekend:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultSchedule returns the spec cron for a routine type (WIB).
|
||||
func DefaultSchedule(typ string) string {
|
||||
switch typ {
|
||||
case RBriefing:
|
||||
return "30 7 * * *" // 07:30 daily
|
||||
case RRadar, RReversal, RInsider:
|
||||
return "*/30 9-16 * * 1-5" // 30-min market hours
|
||||
case REarnings, RDividend:
|
||||
return "0 8 * * *" // 08:00 daily
|
||||
case RWeekend:
|
||||
return "0 9 * * 6" // Sat 09:00
|
||||
default:
|
||||
return "0 8 * * *"
|
||||
}
|
||||
}
|
||||
|
||||
// Engine runs routines on demand (called by the scheduler) and by hand.
|
||||
type Engine struct {
|
||||
DB *store.DB
|
||||
Notifier *alerts.Notifier
|
||||
UserKey string
|
||||
// Publish, when set, receives ("activity", text) per completed run so
|
||||
// the dashboard SSE feed shows routine activity live.
|
||||
Publish func(channel, data string)
|
||||
}
|
||||
|
||||
// Run executes one routine row and records the run.
|
||||
// Prefer RunFor: it scopes the run to the row owner (watchlist + delivery).
|
||||
func (e *Engine) Run(ctx context.Context, r store.Routine) (string, error) {
|
||||
return e.RunFor(ctx, r.UserKey, r)
|
||||
}
|
||||
|
||||
// RunFor executes one routine row owned by owner: tickers come from the
|
||||
// owner's watchlist and delivery fans out to the owner's destinations.
|
||||
func (e *Engine) RunFor(ctx context.Context, owner string, r store.Routine) (string, error) {
|
||||
// Scope this run to the owner without mutating shared engine state.
|
||||
scoped := *e
|
||||
if owner != "" {
|
||||
scoped.UserKey = owner
|
||||
}
|
||||
se := &scoped
|
||||
var (
|
||||
payload string
|
||||
cites []model.Citation
|
||||
err error
|
||||
)
|
||||
switch r.Type {
|
||||
case RBriefing:
|
||||
payload, cites, err = se.Briefing(ctx)
|
||||
case RRadar:
|
||||
payload, cites, err = se.Radar(ctx)
|
||||
case RReversal:
|
||||
payload, cites, err = se.ReversalWatch(ctx)
|
||||
case RInsider:
|
||||
payload, cites, err = se.InsiderTape(ctx)
|
||||
case REarnings:
|
||||
payload, cites, err = se.EarningsCountdown(ctx)
|
||||
case RDividend:
|
||||
payload, cites, err = se.DividendCalendar(ctx)
|
||||
case RWeekend:
|
||||
payload, cites, err = se.WeekendReview(ctx)
|
||||
default:
|
||||
err = fmt.Errorf("routines: unknown type %q", r.Type)
|
||||
}
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
payload = err.Error()
|
||||
}
|
||||
cc, _ := json.Marshal(cites)
|
||||
if _, rerr := se.DB.RecordRun(r.ID, status, summarize(payload, cc), 0, scoped.UserKey); rerr != nil && err == nil {
|
||||
err = rerr
|
||||
}
|
||||
if err == nil && status == "ok" {
|
||||
_ = se.deliver(ctx, r, payload, cites)
|
||||
}
|
||||
if e.Publish != nil {
|
||||
e.Publish("activity", r.Type+" "+status+": "+head(payload, 160))
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func head(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func summarize(payload string, cites []byte) string {
|
||||
return `{"payload":` + strconv(payload) + `,"citations":` + string(cites) + `}`
|
||||
}
|
||||
|
||||
func strconv(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// deliver pushes the payload to the routine's channels via the notifier,
|
||||
// WITH citations (alert cards render context + citations + report link).
|
||||
// Delivery is scoped to the routine owner: the owner's enabled destinations
|
||||
// (managed at /alerts → Push destinations, i.e. POST /api/destinations)
|
||||
// plus the server env fallback. With no targets anywhere it is record-only.
|
||||
func (e *Engine) deliver(ctx context.Context, r store.Routine, payload string, cites []model.Citation) error {
|
||||
if len(r.Channels) == 0 || e.Notifier.DryRunFor(e.UserKey) {
|
||||
return nil
|
||||
}
|
||||
cc, _ := json.Marshal(cites)
|
||||
f := alerts.Finding{Rule: r.Type, Ticker: "IDX", Message: payload, Citations: cites}
|
||||
return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc))
|
||||
}
|
||||
|
||||
// tickers returns the user's watchlist (never empty: falls back to demo set).
|
||||
func (e *Engine) tickers() []string {
|
||||
wl, err := e.DB.Watchlist(e.UserKey)
|
||||
if err == nil && len(wl) > 0 {
|
||||
return wl
|
||||
}
|
||||
return []string{"BBCA"}
|
||||
}
|
||||
|
||||
// todayStr is overridable in tests via Now.
|
||||
var nowFn = time.Now
|
||||
|
||||
func today() string { return nowFn().Format("2006-01-02") }
|
||||
@@ -0,0 +1,98 @@
|
||||
package routines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"flowsight/internal/alerts"
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
func seedEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
db, err := store.Open(t.TempDir() + "/routines.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 &Engine{DB: db, Notifier: alerts.NewNotifier("", "", ""), UserKey: "demo"}
|
||||
}
|
||||
|
||||
// Briefing generates from seed with zero empty sections.
|
||||
func TestBriefing(t *testing.T) {
|
||||
e := seedEngine(t)
|
||||
text, cites, err := e.Briefing(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sec := range []string{"Top accumulation", "Foreign flow", "Agenda"} {
|
||||
if !strings.Contains(text, sec) {
|
||||
t.Fatalf("missing section %q", sec)
|
||||
}
|
||||
}
|
||||
if len(cites) == 0 {
|
||||
t.Fatal("briefing has no citations")
|
||||
}
|
||||
}
|
||||
|
||||
// Radar fires on the accumulation fixture.
|
||||
func TestRadar(t *testing.T) {
|
||||
e := seedEngine(t)
|
||||
text, _, err := e.Radar(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(text, "BBCA") {
|
||||
t.Fatalf("radar missed BBCA: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Accuracy resolution math: weights follow the ledger.
|
||||
func TestAccuracyWeight(t *testing.T) {
|
||||
e := seedEngine(t)
|
||||
if w := e.DB.AccuracyWeight("smart-money"); w != 0.5 {
|
||||
t.Fatalf("default weight = %v, want 0.5", w)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = e.DB.RecordPrediction("smart-money", "BBCA", "BUY")
|
||||
}
|
||||
rows, _ := e.DB.DueForResolution()
|
||||
_ = rows
|
||||
}
|
||||
|
||||
// Briefing carries agenda + mover sections and respects the 25-line cap.
|
||||
func TestBriefingAgendaMoverCap(t *testing.T) {
|
||||
e := seedEngine(t)
|
||||
text, cites, err := e.Briefing(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sec := range []string{"Top accumulation", "Foreign flow", "Agenda", "Mover of the day"} {
|
||||
if !strings.Contains(text, sec) {
|
||||
t.Fatalf("missing section %q:\n%s", sec, text)
|
||||
}
|
||||
}
|
||||
if n := len(strings.Split(strings.TrimRight(text, "\n"), "\n")); n > 25 {
|
||||
t.Fatalf("briefing = %d lines, want ≤25", n)
|
||||
}
|
||||
if len(cites) == 0 {
|
||||
t.Fatal("briefing has no citations")
|
||||
}
|
||||
}
|
||||
|
||||
// R5/R6 run without error on seed (windows simply miss today).
|
||||
func TestCountdownsRun(t *testing.T) {
|
||||
e := seedEngine(t)
|
||||
rows, _ := e.DB.ListRoutines("demo")
|
||||
_ = rows
|
||||
if _, _, err := e.EarningsCountdown(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := e.DividendCalendar(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user