- 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.
162 lines
4.5 KiB
Go
162 lines
4.5 KiB
Go
// 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") }
|