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,89 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ResolveDue resolves predictions older than 30d against actual return:
|
||||
// first stored close on/after predict_date vs latest stored close.
|
||||
// BUY hits above +5%, AVOID hits below -5%, HOLD hits within ±10%.
|
||||
// Returns the number of newly resolved rows.
|
||||
func (db *DB) ResolveDue() (int, error) {
|
||||
due, err := db.DueForResolution()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resolved := 0
|
||||
for _, p := range due {
|
||||
id, _ := p["id"].(int64)
|
||||
ticker, _ := p["ticker"].(string)
|
||||
pred, _ := p["prediction"].(string)
|
||||
since, _ := p["predict_date"].(string)
|
||||
series, err := db.dailyCloses(ticker)
|
||||
if err != nil || len(series) < 2 {
|
||||
continue
|
||||
}
|
||||
base, ok := firstOnOrAfter(series, since)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
last := series[len(series)-1]
|
||||
if base.close <= 0 {
|
||||
continue
|
||||
}
|
||||
ret := (last.close - base.close) / base.close
|
||||
hit := false
|
||||
switch pred {
|
||||
case "BUY":
|
||||
hit = ret > 0.05
|
||||
case "AVOID":
|
||||
hit = ret < -0.05
|
||||
default: // HOLD
|
||||
hit = ret >= -0.10 && ret <= 0.10
|
||||
}
|
||||
if err := db.ResolvePrediction(id, hit, ret); err == nil {
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
type closePoint struct {
|
||||
date string
|
||||
close float64
|
||||
}
|
||||
|
||||
// dailyCloses parses every stored daily payload into a date-sorted series.
|
||||
func (db *DB) dailyCloses(ticker string) ([]closePoint, error) {
|
||||
rows, err := db.Query(`SELECT payload_json FROM snapshots WHERE ticker=? AND source='daily'`, ticker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []closePoint
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range parseDailyBars(raw, "") {
|
||||
if b.Date != "" && b.Close > 0 {
|
||||
out = append(out, closePoint{b.Date, b.Close})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].date < out[j].date })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstOnOrAfter(series []closePoint, date string) (closePoint, bool) {
|
||||
for _, p := range series {
|
||||
if p.date >= date {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return closePoint{}, false
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResolveDue: BUY older than 30d resolves against stored closes.
|
||||
func TestResolveDue(t *testing.T) {
|
||||
db, err := Open(t.TempDir() + "/acc.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
old := time.Now().AddDate(0, 0, -40).Format("2006-01-02")
|
||||
if _, err := db.Exec(`INSERT INTO agent_accuracy(agent,ticker,prediction,predict_date)
|
||||
VALUES('synthesizer','BBCA','BUY',?)`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Rising closes: first bar 9000 -> last 9950 (+10.5%) => BUY hits.
|
||||
payload := `[{"date":"` + old + `","close":9000,"volume":1},{"date":"` +
|
||||
time.Now().Format("2006-01-02") + `","close":9950,"volume":1}]`
|
||||
if err := db.SaveSnapshot("BBCA", time.Now().Format("2006-01-02"), "daily", payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := db.ResolveDue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("resolved = %d, want 1", n)
|
||||
}
|
||||
stats, _ := db.AccuracyStats()
|
||||
if len(stats) != 1 || stats[0]["hits"] != 1 {
|
||||
t.Fatalf("stats = %v, want 1 hit", stats)
|
||||
}
|
||||
// Second run: nothing due.
|
||||
n, _ = db.ResolveDue()
|
||||
if n != 0 {
|
||||
t.Fatalf("resolved = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Cache wraps Redis with an in-memory TTL fallback so the demo runs without
|
||||
// REDIS_URL. Reference data (registry/taxonomy/tags) uses 24h TTL.
|
||||
type Cache struct {
|
||||
rdb *redis.Client
|
||||
mu sync.Mutex
|
||||
mem map[string]memEntry
|
||||
}
|
||||
|
||||
type memEntry struct {
|
||||
val string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// NewCache builds a cache; empty redisURL selects the in-memory fallback.
|
||||
func NewCache(redisURL string) *Cache {
|
||||
c := &Cache{mem: map[string]memEntry{}}
|
||||
if redisURL != "" {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err == nil {
|
||||
c.rdb = redis.NewClient(opt)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// HasRedis reports whether a live Redis backs this cache.
|
||||
func (c *Cache) HasRedis() bool { return c.rdb != nil }
|
||||
|
||||
// Get returns the cached value or "" on miss/expiry.
|
||||
func (c *Cache) Get(ctx context.Context, key string) string {
|
||||
if c.rdb != nil {
|
||||
if v, err := c.rdb.Get(ctx, key).Result(); err == nil {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.mem[key]
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
return ""
|
||||
}
|
||||
return e.val
|
||||
}
|
||||
|
||||
// Set stores a value with TTL.
|
||||
func (c *Cache) Set(ctx context.Context, key, val string, ttl time.Duration) {
|
||||
if c.rdb != nil {
|
||||
_ = c.rdb.Set(ctx, key, val, ttl).Err()
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mem[key] = memEntry{val: val, exp: time.Now().Add(ttl)}
|
||||
}
|
||||
|
||||
// GetJSON unmarshals a cached JSON value; ok=false on miss.
|
||||
func (c *Cache) GetJSON(ctx context.Context, key string, v any) bool {
|
||||
raw := c.Get(ctx, key)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), v) == nil
|
||||
}
|
||||
|
||||
// SetJSON marshals and stores a value with TTL.
|
||||
func (c *Cache) SetJSON(ctx context.Context, key string, v any, ttl time.Duration) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Set(ctx, key, string(b), ttl)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package store persists snapshots, derived tables, routines, alerts, reports,
|
||||
// the accuracy ledger, and the credit ledger on SQLite (modernc.org/sqlite,
|
||||
// pure Go — no CGO so demo machines without gcc still build).
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// DB wraps database/sql with FlowSight helpers.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
Path string
|
||||
}
|
||||
|
||||
// Open connects to path (created if missing) and applies numbered migrations.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqldb, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||
}
|
||||
sqldb.SetMaxOpenConns(1)
|
||||
db := &DB{DB: sqldb, Path: path}
|
||||
if err := db.migrate(); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, "migrations/"+e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
b, err := migrationsFS.ReadFile(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(b)); err != nil {
|
||||
return fmt.Errorf("store: migration %s: %w", n, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Meta helpers persist incremental cursors (quarterly_since, news_since,
|
||||
// filings_since) and dedup markers so restarts resume incrementally.
|
||||
|
||||
// GetMeta returns a meta value or "".
|
||||
func (db *DB) GetMeta(key string) string {
|
||||
var v string
|
||||
_ = db.QueryRow(`SELECT value FROM meta WHERE key=?`, key).Scan(&v)
|
||||
return v
|
||||
}
|
||||
|
||||
// SetMeta upserts a meta value.
|
||||
func (db *DB) SetMeta(key, value string) error {
|
||||
_, err := db.Exec(`INSERT INTO meta(key,value) VALUES(?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
-- FlowSight schema v1. SQLite for hackathon; kept Postgres-compatible
|
||||
-- (integer PKs, TEXT JSON payloads, no SQLite-only DDL).
|
||||
CREATE TABLE IF NOT EXISTS snapshots(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL, date TEXT NOT NULL, source TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL, fetched_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_ticker_date_source ON snapshots(ticker, date, source);
|
||||
CREATE TABLE IF NOT EXISTS broker_activity(
|
||||
broker_code TEXT NOT NULL, ticker TEXT NOT NULL, date TEXT NOT NULL,
|
||||
buy REAL NOT NULL DEFAULT 0, sell REAL NOT NULL DEFAULT 0, net REAL NOT NULL DEFAULT 0,
|
||||
lots REAL NOT NULL DEFAULT 0, freq INTEGER NOT NULL DEFAULT 0, avg_price REAL NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(broker_code, ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_broker_activity_ticker_date ON broker_activity(ticker, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_broker_activity_broker_date ON broker_activity(broker_code, date);
|
||||
CREATE TABLE IF NOT EXISTS foreign_flow(
|
||||
ticker TEXT NOT NULL, date TEXT NOT NULL, net_inflow REAL NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(ticker, date)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS news_items(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL, date TEXT NOT NULL, source TEXT NOT NULL DEFAULT '',
|
||||
sentiment TEXT NOT NULL DEFAULT 'neutral', confidence REAL NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_ticker_date ON news_items(ticker, date);
|
||||
CREATE TABLE IF NOT EXISTS filings(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL, date TEXT NOT NULL, holder_type TEXT NOT NULL DEFAULT '',
|
||||
txn_type TEXT NOT NULL DEFAULT '', volume REAL NOT NULL DEFAULT 0, price REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filings_ticker_date ON filings(ticker, date);
|
||||
CREATE TABLE IF NOT EXISTS routines(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_key TEXT NOT NULL, type TEXT NOT NULL, schedule_cron TEXT NOT NULL DEFAULT '',
|
||||
channels_json TEXT NOT NULL DEFAULT '[]', enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS routine_runs(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
routine_id INTEGER NOT NULL, started_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ok',
|
||||
payload_json TEXT NOT NULL DEFAULT '{}', credits_used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id, started_at);
|
||||
CREATE TABLE IF NOT EXISTS alerts(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_key TEXT NOT NULL, name TEXT NOT NULL, rule_json TEXT NOT NULL,
|
||||
channels_json TEXT NOT NULL DEFAULT '[]', last_fired TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alert_events(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alert_id INTEGER NOT NULL, ticker TEXT NOT NULL, date TEXT NOT NULL,
|
||||
message TEXT NOT NULL, context_json TEXT NOT NULL DEFAULT '{}', citations_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_events_ticker_date ON alert_events(ticker, date);
|
||||
CREATE TABLE IF NOT EXISTS watchlists(
|
||||
user_key TEXT NOT NULL, ticker TEXT NOT NULL, added_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_key, ticker)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reports(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_accuracy(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL, ticker TEXT NOT NULL, prediction TEXT NOT NULL,
|
||||
predict_date TEXT NOT NULL, resolved INTEGER NOT NULL DEFAULT 0,
|
||||
hit INTEGER NOT NULL DEFAULT 0, actual_return REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_accuracy_agent ON agent_accuracy(agent, predict_date);
|
||||
CREATE TABLE IF NOT EXISTS briefings(
|
||||
date TEXT PRIMARY KEY, payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS credit_ledger(
|
||||
date TEXT NOT NULL, endpoint TEXT NOT NULL, calls INTEGER NOT NULL DEFAULT 0,
|
||||
credits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(date, endpoint)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '');
|
||||
@@ -0,0 +1,8 @@
|
||||
-- FlowSight schema v2: holder attribution + user scoping for history reads.
|
||||
ALTER TABLE filings ADD COLUMN holder_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE routine_runs ADD COLUMN user_key TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE alert_events ADD COLUMN user_key TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS idx_routine_runs_user ON routine_runs(user_key, routine_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_key, ticker);
|
||||
UPDATE routine_runs SET user_key=(SELECT user_key FROM routines WHERE routines.id=routine_runs.routine_id) WHERE user_key='';
|
||||
UPDATE alert_events SET user_key=(SELECT user_key FROM alerts WHERE alerts.id=alert_events.alert_id) WHERE user_key='';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- FlowSight schema v3: per-user notification destinations (CRUD, dynamic per login).
|
||||
-- kind: telegram (bot_token+chat_id) or discord (webhook_url). Secrets live
|
||||
-- server-side only; API responses never include them.
|
||||
CREATE TABLE IF NOT EXISTS notification_destinations(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
webhook_url TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_destinations_user ON notification_destinations(user_key, enabled);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// Retention prune keeps newest per (ticker, source) and deletes old rows.
|
||||
func TestPruneSnapshots(t *testing.T) {
|
||||
db, err := Open(t.TempDir() + "/prune.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
_ = db.SaveSnapshot("OLD", "2020-01-01", "daily", `[{"close":1}]`)
|
||||
_ = db.SaveSnapshot("OLD", "2026-09-11", "daily", `[{"close":2}]`)
|
||||
_ = db.SaveSnapshot("NEW", "2026-09-11", "daily", `[{"close":3}]`)
|
||||
n, err := db.PruneSnapshots("2026-01-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("pruned = %d, want 1", n)
|
||||
}
|
||||
if _, _, err := db.LatestSnapshot("OLD", "daily"); err != nil {
|
||||
t.Fatal("newest OLD row must survive")
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteRoutine + FilingsSince + ListReports parity.
|
||||
func TestStoreParity(t *testing.T) {
|
||||
db, err := Open(t.TempDir() + "/parity.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
id, _ := db.CreateRoutine("demo", "weekend-review", "0 9 * * 6", nil)
|
||||
ok, err := db.DeleteRoutine(id, "demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete = %v, %v", ok, err)
|
||||
}
|
||||
if ok2, _ := db.DeleteRoutine(999999, "demo"); ok2 {
|
||||
t.Fatal("delete of missing id must report false")
|
||||
}
|
||||
rows, _ := db.ListRoutines("demo")
|
||||
for _, r := range rows {
|
||||
if r.ID == id {
|
||||
t.Fatal("routine not deleted")
|
||||
}
|
||||
}
|
||||
_ = db.InsertFiling("BBCA", "2026-09-10", "insider", "buy", 500000, 9850)
|
||||
fils, err := db.FilingsSince("BBCA", "2026-09-01", 10)
|
||||
if err != nil || len(fils) != 1 {
|
||||
t.Fatalf("filings = %v, %v", fils, err)
|
||||
}
|
||||
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
|
||||
reps, err := db.ListReports("BBCA", 10)
|
||||
if err != nil || len(reps) != 1 {
|
||||
t.Fatalf("reports = %v, %v", reps, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Destination CRUD: masks nothing at store layer, scopes by owner, kind immutable via API.
|
||||
func TestDestinationCRUD(t *testing.T) {
|
||||
db, err := Open(t.TempDir() + "/dest.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
id, err := db.CreateDestination(Destination{UserKey: "u1", Kind: DestTelegram, Label: "bot", BotToken: "tok", ChatID: "123", Enabled: true})
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("create = %d, %v", id, err)
|
||||
}
|
||||
rows, _ := db.ListDestinations("u1")
|
||||
if len(rows) != 1 || rows[0].BotToken != "tok" {
|
||||
t.Fatalf("list = %+v", rows)
|
||||
}
|
||||
if other, _ := db.ListDestinations("u2"); len(other) != 0 {
|
||||
t.Fatal("cross-user leak")
|
||||
}
|
||||
en, _ := db.ListEnabledDestinations("u1")
|
||||
if len(en) != 1 {
|
||||
t.Fatal("enabled list must include it")
|
||||
}
|
||||
off := false
|
||||
ok, err := db.UpdateDestination(id, "u1", nil, &off, nil, nil, nil)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("disable = %v, %v", ok, err)
|
||||
}
|
||||
if en, _ := db.ListEnabledDestinations("u1"); len(en) != 0 {
|
||||
t.Fatal("disabled row must drop from enabled list")
|
||||
}
|
||||
// Omitted secrets keep stored values.
|
||||
ok, err = db.UpdateDestination(id, "u1", nil, nil, nil, nil, nil)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("noop patch = %v, %v", ok, err)
|
||||
}
|
||||
got, _ := db.GetDestination(id, "u1")
|
||||
if got == nil || got.BotToken != "tok" {
|
||||
t.Fatalf("secrets must persist on empty patch: %+v", got)
|
||||
}
|
||||
if ok, _ := db.UpdateDestination(id, "u2", nil, nil, nil, nil, nil); ok {
|
||||
t.Fatal("cross-user patch must report false")
|
||||
}
|
||||
ok, err = db.DeleteDestination(id, "u1")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete = %v, %v", ok, err)
|
||||
}
|
||||
if ok, _ := db.DeleteDestination(id, "u1"); ok {
|
||||
t.Fatal("double delete must report false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Seed loads one historical trading week into snapshots + derived tables so
|
||||
// the full demo (briefing → radar → report → interrogation) runs offline.
|
||||
// Fixtures live in tests/fixtures/ as JSON exports of real API shapes;
|
||||
// seed/manifest.json maps each file to (ticker, date, source).
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Manifest describes one historical-replay seed bundle.
|
||||
type Manifest struct {
|
||||
Week string `json:"week"`
|
||||
Snapshots []ManifestSnap `json:"snapshots"`
|
||||
Watchlist []string `json:"watchlist"`
|
||||
Routines []ManifestRoutine `json:"routines"`
|
||||
}
|
||||
|
||||
// ManifestSnap maps one fixture file to a snapshots row.
|
||||
type ManifestSnap struct {
|
||||
Ticker string `json:"ticker"`
|
||||
Date string `json:"date"`
|
||||
Source string `json:"source"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// ManifestRoutine seeds one subscribed routine.
|
||||
type ManifestRoutine struct {
|
||||
UserKey string `json:"user_key"`
|
||||
Type string `json:"type"`
|
||||
Schedule string `json:"schedule"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// SeedStats counts what the seed loaded.
|
||||
type SeedStats struct {
|
||||
Snapshots int
|
||||
Watchlist int
|
||||
Routines int
|
||||
}
|
||||
|
||||
// NeedsSeed reports whether the DB has no snapshots yet.
|
||||
func (db *DB) NeedsSeed() bool {
|
||||
var n int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM snapshots`).Scan(&n)
|
||||
return n == 0
|
||||
}
|
||||
|
||||
// SeedFromDir loads a fixture bundle. It is idempotent for snapshots
|
||||
// (skips exact ticker/date/source duplicates) so re-runs are safe.
|
||||
func (db *DB) SeedFromDir(dir, userKey string) (SeedStats, error) {
|
||||
var st SeedStats
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("store: read manifest: %w", err)
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return st, fmt.Errorf("store: decode manifest: %w", err)
|
||||
}
|
||||
for _, s := range m.Snapshots {
|
||||
body, err := os.ReadFile(filepath.Join(dir, s.File))
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("store: read fixture %s: %w", s.File, err)
|
||||
}
|
||||
if !validJSON(body) {
|
||||
return st, fmt.Errorf("store: fixture %s is not valid JSON", s.File)
|
||||
}
|
||||
var n int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM snapshots WHERE ticker=? AND date=? AND source=?`,
|
||||
s.Ticker, s.Date, s.Source).Scan(&n)
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.SaveSnapshot(s.Ticker, s.Date, s.Source, string(body)); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Snapshots++
|
||||
if err := db.derive(s.Ticker, s.Date, s.Source, body); err != nil {
|
||||
return st, err
|
||||
}
|
||||
}
|
||||
for _, t := range m.Watchlist {
|
||||
if err := db.AddWatch(userKey, t); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Watchlist++
|
||||
}
|
||||
existing, _ := db.ListRoutines(userKey)
|
||||
have := map[string]bool{}
|
||||
for _, r := range existing {
|
||||
have[r.Type] = true
|
||||
}
|
||||
for _, r := range m.Routines {
|
||||
if have[r.Type] {
|
||||
continue
|
||||
}
|
||||
key := r.UserKey
|
||||
if key == "" {
|
||||
key = userKey
|
||||
}
|
||||
if _, err := db.CreateRoutine(key, r.Type, r.Schedule, r.Channels); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Routines++
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func validJSON(b []byte) bool {
|
||||
var v any
|
||||
return json.Unmarshal(b, &v) == nil
|
||||
}
|
||||
|
||||
// derive populates typed tables from raw snapshot payloads so rules and
|
||||
// agents can query without re-parsing JSON on every cycle.
|
||||
func (db *DB) derive(ticker, date, source string, body []byte) error {
|
||||
switch source {
|
||||
case "broker-summary-top":
|
||||
var resp struct {
|
||||
TopBuyers []struct {
|
||||
BrokerCode string `json:"broker_code"`
|
||||
NetIDR float64 `json:"net_idr"`
|
||||
BuyIDR float64 `json:"buy_idr"`
|
||||
SellIDR float64 `json:"sell_idr"`
|
||||
} `json:"top_buyers"`
|
||||
TopSellers []struct {
|
||||
BrokerCode string `json:"broker_code"`
|
||||
NetIDR float64 `json:"net_idr"`
|
||||
BuyIDR float64 `json:"buy_idr"`
|
||||
SellIDR float64 `json:"sell_idr"`
|
||||
} `json:"top_sellers"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range resp.TopBuyers {
|
||||
if err := db.UpsertBrokerActivity(b.BrokerCode, ticker, date,
|
||||
b.BuyIDR, b.SellIDR, b.NetIDR, 0, 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, s := range resp.TopSellers {
|
||||
if err := db.UpsertBrokerActivity(s.BrokerCode, ticker, date,
|
||||
s.BuyIDR, s.SellIDR, s.NetIDR, 0, 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "foreign-flow":
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
Date string `json:"date"`
|
||||
NetForeignInflow float64 `json:"net_foreign_inflow"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range resp.Data {
|
||||
if err := db.UpsertForeignFlow(ticker, d.Date, d.NetForeignInflow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "news":
|
||||
var resp struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range resp.Results {
|
||||
d := date
|
||||
if len(a.Timestamp) >= 10 {
|
||||
d = a.Timestamp[:10]
|
||||
}
|
||||
if err := db.InsertNews(ticker, d, a.Source, "neutral", 0.5, a.Source, a.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "filings":
|
||||
var resp struct {
|
||||
Results []struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
HolderType string `json:"holder_type"`
|
||||
HolderName string `json:"holder_name"`
|
||||
AmountTransaction float64 `json:"amount_transaction"`
|
||||
Price float64 `json:"price"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range resp.Results {
|
||||
d := date
|
||||
if len(f.Timestamp) >= 10 {
|
||||
d = f.Timestamp[:10]
|
||||
}
|
||||
if err := db.InsertFiling(ticker, d, f.HolderType, f.TransactionType,
|
||||
f.AmountTransaction, f.Price, f.HolderName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Seed loads the historical week; row counts must match the fixture manifest.
|
||||
func TestSeed(t *testing.T) {
|
||||
db, err := Open(t.TempDir() + "/seed.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st, err := db.SeedFromDir("../../tests/fixtures", "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Snapshots != 23 {
|
||||
t.Fatalf("snapshots = %d, want 23", st.Snapshots)
|
||||
}
|
||||
if st.Watchlist != 2 {
|
||||
t.Fatalf("watchlist = %d, want 2", st.Watchlist)
|
||||
}
|
||||
if got := db.SnapshotCount("daily"); got != 1 {
|
||||
t.Fatalf("daily snapshots = %d, want 1", got)
|
||||
}
|
||||
// Idempotent re-seed.
|
||||
st2, err := db.SeedFromDir("../../tests/fixtures", "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st2.Snapshots != 0 {
|
||||
t.Fatalf("re-seed snapshots = %d, want 0", st2.Snapshots)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user