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
+89
View File
@@ -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
}
+42
View File
@@ -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)
}
}
+84
View File
@@ -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)
}
+78
View File
@@ -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
+109
View File
@@ -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")
}
}
+213
View File
@@ -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
}
+35
View File
@@ -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)
}
}