idx-cli/msn/db.go

1054 lines
32 KiB
Go

package msn
import (
"database/sql"
"encoding/json"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
// StockDB manages SQLite database operations
type StockDB struct {
db *sql.DB
}
// NewStockDB creates a new database connection and initializes schema
func NewStockDB(dbPath string) (*StockDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
sdb := &StockDB{db: db}
if err := sdb.initSchema(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
return sdb, nil
}
// Close closes the database connection
func (s *StockDB) Close() error {
return s.db.Close()
}
// initSchema creates all database tables
func (s *StockDB) initSchema() error {
schema := `
-- Core stock info (updated each run)
CREATE TABLE IF NOT EXISTS stocks (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL,
name TEXT,
display_name TEXT,
sector TEXT,
industry TEXT,
exchange_id TEXT,
exchange_code TEXT,
exchange_name TEXT,
country TEXT,
currency TEXT,
market TEXT,
website TEXT,
employees INTEGER,
description TEXT,
address TEXT,
city TEXT,
phone TEXT,
last_updated DATETIME,
last_seen DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stocks_ticker ON stocks(ticker);
CREATE INDEX IF NOT EXISTS idx_stocks_sector ON stocks(sector);
CREATE INDEX IF NOT EXISTS idx_stocks_last_seen ON stocks(last_seen);
-- Price snapshots (historical - one row per stock per day)
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
scrape_date DATE NOT NULL,
price REAL,
price_change REAL,
price_change_pct REAL,
price_open REAL,
price_high REAL,
price_low REAL,
price_close REAL,
price_prev_close REAL,
price_52w_high REAL,
price_52w_low REAL,
volume REAL,
avg_volume REAL,
market_cap REAL,
market_cap_currency TEXT,
price_change_1w REAL,
price_change_1m REAL,
price_change_3m REAL,
price_change_6m REAL,
price_change_ytd REAL,
price_change_1y REAL,
return_1w REAL,
return_1m REAL,
return_3m REAL,
return_6m REAL,
return_ytd REAL,
return_1y REAL,
time_last_traded TEXT,
UNIQUE(stock_id, scrape_date),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_price_history_stock ON price_history(stock_id);
CREATE INDEX IF NOT EXISTS idx_price_history_date ON price_history(scrape_date);
-- Financial ratios (historical - per year per stock)
CREATE TABLE IF NOT EXISTS ratios_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
scrape_date DATE NOT NULL,
year TEXT,
fiscal_period TEXT,
pe_ratio REAL,
pb_ratio REAL,
ps_ratio REAL,
pcf_ratio REAL,
ev_ebitda REAL,
dividend_yield REAL,
payout_ratio REAL,
roe REAL,
roa REAL,
roic REAL,
gross_margin REAL,
operating_margin REAL,
net_margin REAL,
debt_to_equity REAL,
debt_to_ebitda REAL,
financial_leverage REAL,
current_ratio REAL,
quick_ratio REAL,
asset_turnover REAL,
inventory_turnover REAL,
receivable_turnover REAL,
revenue_growth REAL,
earnings_growth REAL,
eps REAL,
bvps REAL,
revenue_per_share REAL,
fcf_per_share REAL,
dividend_per_share REAL,
UNIQUE(stock_id, scrape_date, year),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_ratios_history_stock ON ratios_history(stock_id);
-- Balance sheets
CREATE TABLE IF NOT EXISTS balance_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
report_date DATE,
end_date DATE,
source TEXT,
source_date DATE,
current_assets_json TEXT,
long_term_assets_json TEXT,
current_liabilities_json TEXT,
equity_json TEXT,
currency TEXT,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, end_date),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
-- Cash flows
CREATE TABLE IF NOT EXISTS cash_flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
report_date DATE,
end_date DATE,
source TEXT,
operating_json TEXT,
investing_json TEXT,
financing_json TEXT,
currency TEXT,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, end_date),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
-- Income statements
CREATE TABLE IF NOT EXISTS income_statements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
report_date DATE,
end_date DATE,
source TEXT,
revenue_json TEXT,
expenses_json TEXT,
currency TEXT,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, end_date),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
-- Earnings events
CREATE TABLE IF NOT EXISTS earnings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
event_date DATE,
fiscal_year INTEGER,
fiscal_quarter INTEGER,
eps_estimate REAL,
eps_actual REAL,
eps_surprise REAL,
eps_surprise_pct REAL,
revenue_estimate REAL,
revenue_actual REAL,
revenue_surprise REAL,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, event_date, fiscal_year, fiscal_quarter),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_earnings_stock ON earnings(stock_id);
-- Chart OHLCV data
CREATE TABLE IF NOT EXISTS charts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
timeframe TEXT NOT NULL,
timestamp DATETIME NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume INTEGER,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, timeframe, timestamp),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_charts_stock_tf ON charts(stock_id, timeframe);
-- News articles
CREATE TABLE IF NOT EXISTS news (
id TEXT PRIMARY KEY,
stock_id TEXT NOT NULL,
title TEXT,
url TEXT,
abstract TEXT,
source_id TEXT,
source_name TEXT,
published_at DATETIME,
read_time_min INTEGER,
image_url TEXT,
image_width INTEGER,
image_height INTEGER,
news_type TEXT,
category TEXT,
sentiment TEXT,
sentiment_score REAL,
is_critical INTEGER DEFAULT 0,
first_seen DATE NOT NULL,
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_news_stock ON news(stock_id);
CREATE INDEX IF NOT EXISTS idx_news_published ON news(published_at);
CREATE INDEX IF NOT EXISTS idx_news_category ON news(category);
CREATE INDEX IF NOT EXISTS idx_news_critical ON news(is_critical);
-- Sentiment data (historical)
CREATE TABLE IF NOT EXISTS sentiment_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
scrape_date DATE NOT NULL,
time_range TEXT,
time_range_enum TEXT,
start_time INTEGER,
end_time INTEGER,
bullish INTEGER,
bearish INTEGER,
neutral INTEGER,
bullish_pct REAL,
bearish_pct REAL,
neutral_pct REAL,
scenario TEXT,
UNIQUE(stock_id, scrape_date, time_range_enum),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_sentiment_stock ON sentiment_history(stock_id);
-- AI Insights
CREATE TABLE IF NOT EXISTS insights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
scrape_date DATE NOT NULL,
summary TEXT,
highlights_json TEXT,
risks_json TEXT,
last_updated TEXT,
UNIQUE(stock_id, scrape_date),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
-- Ownership data
CREATE TABLE IF NOT EXISTS ownership (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
scrape_date DATE NOT NULL,
holder_type TEXT,
investor_name TEXT,
investor_type TEXT,
shares_held INTEGER,
shares_change INTEGER,
shares_pct REAL,
value REAL,
report_date DATE,
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_ownership_stock ON ownership(stock_id);
-- Company officers
CREATE TABLE IF NOT EXISTS officers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_id TEXT NOT NULL,
name TEXT,
title TEXT,
age INTEGER,
year_born INTEGER,
total_pay INTEGER,
as_of_date DATE,
scrape_date DATE NOT NULL,
UNIQUE(stock_id, name, title),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
-- Scrape runs tracking
CREATE TABLE IF NOT EXISTS scrape_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at DATETIME NOT NULL,
completed_at DATETIME,
status TEXT NOT NULL DEFAULT 'running',
total_stocks INTEGER,
successful INTEGER DEFAULT 0,
failed INTEGER DEFAULT 0,
config_json TEXT
);
-- Progress tracking (for resume)
CREATE TABLE IF NOT EXISTS scrape_progress (
run_id INTEGER NOT NULL,
stock_id TEXT NOT NULL,
ticker TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
apis_success INTEGER DEFAULT 0,
apis_failed INTEGER DEFAULT 0,
started_at DATETIME,
completed_at DATETIME,
PRIMARY KEY(run_id, stock_id),
FOREIGN KEY(run_id) REFERENCES scrape_runs(id)
);
CREATE INDEX IF NOT EXISTS idx_progress_status ON scrape_progress(run_id, status);
-- Stock indices (LQ45, IDX80, etc.)
CREATE TABLE IF NOT EXISTS stock_indices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
index_name TEXT NOT NULL,
stock_id TEXT NOT NULL,
ticker TEXT NOT NULL,
added_date DATE,
UNIQUE(index_name, stock_id),
FOREIGN KEY(stock_id) REFERENCES stocks(id)
);
CREATE INDEX IF NOT EXISTS idx_stock_indices_name ON stock_indices(index_name);
`
_, err := s.db.Exec(schema)
return err
}
// StartScrapeRun creates a new scrape run and returns its ID
func (s *StockDB) StartScrapeRun(totalStocks int, config map[string]interface{}) (int64, error) {
configJSON, _ := json.Marshal(config)
result, err := s.db.Exec(`
INSERT INTO scrape_runs (started_at, status, total_stocks, config_json)
VALUES (?, 'running', ?, ?)
`, time.Now().UTC(), totalStocks, string(configJSON))
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// InitProgress initializes progress for all stocks in a run
func (s *StockDB) InitProgress(runID int64, stocks map[string]IDXStock) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT OR IGNORE INTO scrape_progress (run_id, stock_id, ticker, status)
VALUES (?, ?, ?, 'pending')
`)
if err != nil {
return err
}
defer stmt.Close()
for ticker, stock := range stocks {
_, err := stmt.Exec(runID, stock.ID, ticker)
if err != nil {
return err
}
}
return tx.Commit()
}
// GetPendingStocks returns stocks that haven't been processed in this run
func (s *StockDB) GetPendingStocks(runID int64) ([]struct {
ID string
Ticker string
}, error) {
rows, err := s.db.Query(`
SELECT stock_id, ticker FROM scrape_progress
WHERE run_id = ? AND status = 'pending'
ORDER BY ticker
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var stocks []struct {
ID string
Ticker string
}
for rows.Next() {
var stock struct {
ID string
Ticker string
}
if err := rows.Scan(&stock.ID, &stock.Ticker); err != nil {
return nil, err
}
stocks = append(stocks, stock)
}
return stocks, nil
}
// UpdateProgress updates the progress of a stock
func (s *StockDB) UpdateProgress(runID int64, stockID string, status string, apisSuccess, apisFailed int, errMsg string) error {
_, err := s.db.Exec(`
UPDATE scrape_progress
SET status = ?, apis_success = ?, apis_failed = ?, error_message = ?, completed_at = ?
WHERE run_id = ? AND stock_id = ?
`, status, apisSuccess, apisFailed, errMsg, time.Now().UTC(), runID, stockID)
return err
}
// MarkProgressStarted marks a stock as started
func (s *StockDB) MarkProgressStarted(runID int64, stockID string) error {
_, err := s.db.Exec(`
UPDATE scrape_progress SET status = 'processing', started_at = ?
WHERE run_id = ? AND stock_id = ?
`, time.Now().UTC(), runID, stockID)
return err
}
// CompleteScrapeRun marks a scrape run as completed
func (s *StockDB) CompleteScrapeRun(runID int64, status string) error {
// Count successful and failed
var successful, failed int
s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'success'`, runID).Scan(&successful)
s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'failed'`, runID).Scan(&failed)
_, err := s.db.Exec(`
UPDATE scrape_runs SET completed_at = ?, status = ?, successful = ?, failed = ?
WHERE id = ?
`, time.Now().UTC(), status, successful, failed, runID)
return err
}
// GetLastRunID returns the most recent incomplete run ID (for resume)
func (s *StockDB) GetLastRunID() (int64, error) {
var runID int64
err := s.db.QueryRow(`
SELECT id FROM scrape_runs
WHERE status IN ('running', 'interrupted')
ORDER BY started_at DESC LIMIT 1
`).Scan(&runID)
if err == sql.ErrNoRows {
return 0, nil
}
return runID, err
}
// SaveStockData saves all stock data to the database
func (s *StockDB) SaveStockData(stock *StockData) error {
today := time.Now().UTC().Format("2006-01-02")
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Save core stock info
if err := s.saveStock(tx, stock, today); err != nil {
return fmt.Errorf("save stock: %w", err)
}
// Save price history
if stock.Quote != nil {
if err := s.savePriceHistory(tx, stock, today); err != nil {
return fmt.Errorf("save price: %w", err)
}
}
// Save ratios
if stock.KeyRatios != nil {
if err := s.saveRatios(tx, stock, today); err != nil {
return fmt.Errorf("save ratios: %w", err)
}
}
// Save financials
if stock.Financials != nil {
if err := s.saveFinancials(tx, stock, today); err != nil {
return fmt.Errorf("save financials: %w", err)
}
}
// Save earnings
if len(stock.Earnings) > 0 {
if err := s.saveEarnings(tx, stock, today); err != nil {
return fmt.Errorf("save earnings: %w", err)
}
}
// Save charts
if len(stock.Charts) > 0 {
if err := s.saveCharts(tx, stock, today); err != nil {
return fmt.Errorf("save charts: %w", err)
}
}
// Save news
if len(stock.News) > 0 {
if err := s.saveNews(tx, stock, today); err != nil {
return fmt.Errorf("save news: %w", err)
}
}
// Save sentiment
if stock.Sentiment != nil {
if err := s.saveSentiment(tx, stock, today); err != nil {
return fmt.Errorf("save sentiment: %w", err)
}
}
// Save insights
if stock.Insights != nil {
if err := s.saveInsights(tx, stock, today); err != nil {
return fmt.Errorf("save insights: %w", err)
}
}
// Save ownership
if stock.Ownership != nil {
if err := s.saveOwnership(tx, stock, today); err != nil {
return fmt.Errorf("save ownership: %w", err)
}
}
return tx.Commit()
}
func (s *StockDB) saveStock(tx *sql.Tx, stock *StockData, today string) error {
var website, description, address, city, phone string
var employees int
if stock.Company != nil {
website = stock.Company.Website
description = stock.Company.Description
address = stock.Company.Address
city = stock.Company.City
phone = stock.Company.Phone
employees = stock.Company.Employees
}
var exchangeCode, exchangeName, country, currency, market, displayName string
if stock.Quote != nil {
exchangeCode = stock.Quote.ExchangeCode
exchangeName = stock.Quote.ExchangeName
country = stock.Quote.Country
currency = stock.Quote.Currency
market = stock.Quote.Market
displayName = stock.Quote.DisplayName
}
_, err := tx.Exec(`
INSERT INTO stocks (id, ticker, name, display_name, sector, industry,
exchange_id, exchange_code, exchange_name, country, currency, market,
website, employees, description, address, city, phone, last_updated, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ticker = excluded.ticker,
name = excluded.name,
display_name = excluded.display_name,
sector = COALESCE(excluded.sector, sector),
industry = COALESCE(excluded.industry, industry),
exchange_id = COALESCE(excluded.exchange_id, exchange_id),
exchange_code = COALESCE(excluded.exchange_code, exchange_code),
exchange_name = COALESCE(excluded.exchange_name, exchange_name),
country = COALESCE(excluded.country, country),
currency = COALESCE(excluded.currency, currency),
market = COALESCE(excluded.market, market),
website = COALESCE(excluded.website, website),
employees = COALESCE(excluded.employees, employees),
description = COALESCE(excluded.description, description),
address = COALESCE(excluded.address, address),
city = COALESCE(excluded.city, city),
phone = COALESCE(excluded.phone, phone),
last_updated = excluded.last_updated,
last_seen = excluded.last_seen
`, stock.ID, stock.Ticker, stock.Name, displayName, stock.Sector, stock.Industry,
stock.Exchange, exchangeCode, exchangeName, country, currency, market,
website, employees, description, address, city, phone, stock.FetchedAt, today)
return err
}
func (s *StockDB) savePriceHistory(tx *sql.Tx, stock *StockData, today string) error {
q := stock.Quote
_, err := tx.Exec(`
INSERT INTO price_history (stock_id, scrape_date, price, price_change, price_change_pct,
price_open, price_high, price_low, price_close, price_prev_close,
price_52w_high, price_52w_low, volume, avg_volume, market_cap, market_cap_currency,
price_change_1w, price_change_1m, price_change_3m, price_change_6m, price_change_ytd, price_change_1y,
return_1w, return_1m, return_3m, return_6m, return_ytd, return_1y, time_last_traded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, scrape_date) DO UPDATE SET
price = excluded.price,
price_change = excluded.price_change,
price_change_pct = excluded.price_change_pct,
price_open = excluded.price_open,
price_high = excluded.price_high,
price_low = excluded.price_low,
price_close = excluded.price_close,
price_prev_close = excluded.price_prev_close,
price_52w_high = excluded.price_52w_high,
price_52w_low = excluded.price_52w_low,
volume = excluded.volume,
avg_volume = excluded.avg_volume,
market_cap = excluded.market_cap,
market_cap_currency = excluded.market_cap_currency,
price_change_1w = excluded.price_change_1w,
price_change_1m = excluded.price_change_1m,
price_change_3m = excluded.price_change_3m,
price_change_6m = excluded.price_change_6m,
price_change_ytd = excluded.price_change_ytd,
price_change_1y = excluded.price_change_1y,
return_1w = excluded.return_1w,
return_1m = excluded.return_1m,
return_3m = excluded.return_3m,
return_6m = excluded.return_6m,
return_ytd = excluded.return_ytd,
return_1y = excluded.return_1y,
time_last_traded = excluded.time_last_traded
`, stock.ID, today, q.Price, q.PriceChange, q.PriceChangePct,
q.PriceDayOpen, q.PriceDayHigh, q.PriceDayLow, q.PriceClose, q.PricePreviousClose,
q.Price52wHigh, q.Price52wLow, q.AccumulatedVolume, q.AverageVolume, q.MarketCap, q.MarketCapCurrency,
q.PriceChange1Week, q.PriceChange1Month, q.PriceChange3Month, q.PriceChange6Month, q.PriceChangeYTD, q.PriceChange1Year,
q.Return1Week, q.Return1Month, q.Return3Month, q.Return6Month, q.ReturnYTD, q.Return1Year, q.TimeLastTraded)
return err
}
func (s *StockDB) saveRatios(tx *sql.Tx, stock *StockData, today string) error {
// Get current price for dividend yield calculation
var currentPrice float64
if stock.Quote != nil && stock.Quote.Price > 0 {
currentPrice = stock.Quote.Price
}
for _, metric := range stock.KeyRatios.IndustryMetrics {
// Calculate dividend yield: (DividendPerShare / Price) * 100
var dividendYield float64
if currentPrice > 0 && metric.DividendPerShare > 0 {
dividendYield = (metric.DividendPerShare / currentPrice) * 100
}
_, err := tx.Exec(`
INSERT INTO ratios_history (stock_id, scrape_date, year, fiscal_period,
pe_ratio, pb_ratio, ps_ratio, pcf_ratio, ev_ebitda,
dividend_yield, payout_ratio, roe, roa, roic,
gross_margin, operating_margin, net_margin,
debt_to_equity, debt_to_ebitda, financial_leverage,
current_ratio, quick_ratio, asset_turnover, inventory_turnover, receivable_turnover,
revenue_growth, earnings_growth, eps, bvps, revenue_per_share, fcf_per_share, dividend_per_share)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, scrape_date, year) DO UPDATE SET
fiscal_period = excluded.fiscal_period,
pe_ratio = excluded.pe_ratio,
pb_ratio = excluded.pb_ratio,
ps_ratio = excluded.ps_ratio,
pcf_ratio = excluded.pcf_ratio,
ev_ebitda = excluded.ev_ebitda,
dividend_yield = excluded.dividend_yield,
payout_ratio = excluded.payout_ratio,
roe = excluded.roe,
roa = excluded.roa,
roic = excluded.roic,
gross_margin = excluded.gross_margin,
operating_margin = excluded.operating_margin,
net_margin = excluded.net_margin,
debt_to_equity = excluded.debt_to_equity,
debt_to_ebitda = excluded.debt_to_ebitda,
financial_leverage = excluded.financial_leverage,
current_ratio = excluded.current_ratio,
quick_ratio = excluded.quick_ratio,
asset_turnover = excluded.asset_turnover,
inventory_turnover = excluded.inventory_turnover,
receivable_turnover = excluded.receivable_turnover,
revenue_growth = excluded.revenue_growth,
earnings_growth = excluded.earnings_growth,
eps = excluded.eps,
bvps = excluded.bvps,
revenue_per_share = excluded.revenue_per_share,
fcf_per_share = excluded.fcf_per_share,
dividend_per_share = excluded.dividend_per_share
`, stock.ID, today, metric.Year, metric.FiscalPeriodType,
metric.PriceToEarningsRatio, metric.PriceToBookRatio, metric.PriceToSalesRatio, metric.PriceToCashFlowRatio, metric.EVToEBITDA,
dividendYield, metric.PayoutRatio, metric.ROE, metric.ROA, metric.ROIC,
metric.GrossMargin, metric.OperatingMargin, metric.NetMargin,
metric.DebtToEquityRatio, metric.DebtToEBITDA, metric.FinancialLeverage,
metric.CurrentRatio, metric.QuickRatio, metric.AssetTurnover, metric.InventoryTurnover, metric.ReceivableTurnover,
metric.RevenueGrowthRate, metric.EarningsGrowthRate, metric.EarningsPerShare, metric.BookValuePerShare,
metric.RevenuePerShare, metric.FreeCashFlowPerShare, metric.DividendPerShare)
if err != nil {
return err
}
}
return nil
}
func (s *StockDB) saveFinancials(tx *sql.Tx, stock *StockData, today string) error {
for _, stmt := range stock.Financials.Statements {
// Save balance sheet
if stmt.BalanceSheets != nil {
currentAssetsJSON, _ := json.Marshal(stmt.BalanceSheets.CurrentAssets)
longTermAssetsJSON, _ := json.Marshal(stmt.BalanceSheets.LongTermAssets)
currentLiabilitiesJSON, _ := json.Marshal(stmt.BalanceSheets.CurrentLiabilities)
equityJSON, _ := json.Marshal(stmt.BalanceSheets.Equity)
_, err := tx.Exec(`
INSERT INTO balance_sheets (stock_id, report_date, end_date, source, source_date,
current_assets_json, long_term_assets_json, current_liabilities_json, equity_json, currency, scrape_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, end_date) DO UPDATE SET
report_date = excluded.report_date,
source = excluded.source,
source_date = excluded.source_date,
current_assets_json = excluded.current_assets_json,
long_term_assets_json = excluded.long_term_assets_json,
current_liabilities_json = excluded.current_liabilities_json,
equity_json = excluded.equity_json,
currency = excluded.currency,
scrape_date = excluded.scrape_date
`, stock.ID, stmt.BalanceSheets.ReportDate, stmt.BalanceSheets.EndDate, stmt.BalanceSheets.Source, stmt.BalanceSheets.SourceDate,
string(currentAssetsJSON), string(longTermAssetsJSON), string(currentLiabilitiesJSON), string(equityJSON),
stmt.BalanceSheets.Currency, today)
if err != nil {
return err
}
}
// Save cash flow
if stmt.CashFlow != nil {
operatingJSON, _ := json.Marshal(stmt.CashFlow.Operating)
investingJSON, _ := json.Marshal(stmt.CashFlow.Investing)
financingJSON, _ := json.Marshal(stmt.CashFlow.Financing)
_, err := tx.Exec(`
INSERT INTO cash_flows (stock_id, end_date, source, operating_json, investing_json, financing_json, currency, scrape_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, end_date) DO UPDATE SET
source = excluded.source,
operating_json = excluded.operating_json,
investing_json = excluded.investing_json,
financing_json = excluded.financing_json,
currency = excluded.currency,
scrape_date = excluded.scrape_date
`, stock.ID, stmt.CashFlow.EndDate, stmt.CashFlow.Source,
string(operatingJSON), string(investingJSON), string(financingJSON),
stmt.CashFlow.Currency, today)
if err != nil {
return err
}
}
// Save income statement
if stmt.IncomeStatements != nil {
revenueJSON, _ := json.Marshal(stmt.IncomeStatements.Revenue)
expensesJSON, _ := json.Marshal(stmt.IncomeStatements.Expenses)
_, err := tx.Exec(`
INSERT INTO income_statements (stock_id, end_date, source, revenue_json, expenses_json, currency, scrape_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, end_date) DO UPDATE SET
source = excluded.source,
revenue_json = excluded.revenue_json,
expenses_json = excluded.expenses_json,
currency = excluded.currency,
scrape_date = excluded.scrape_date
`, stock.ID, stmt.IncomeStatements.EndDate, stmt.IncomeStatements.Source,
string(revenueJSON), string(expensesJSON), stmt.IncomeStatements.Currency, today)
if err != nil {
return err
}
}
}
return nil
}
func (s *StockDB) saveEarnings(tx *sql.Tx, stock *StockData, today string) error {
for _, e := range stock.Earnings {
_, err := tx.Exec(`
INSERT INTO earnings (stock_id, event_date, fiscal_year, fiscal_quarter,
eps_estimate, eps_actual, eps_surprise, eps_surprise_pct,
revenue_estimate, revenue_actual, revenue_surprise, scrape_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, event_date, fiscal_year, fiscal_quarter) DO UPDATE SET
eps_estimate = excluded.eps_estimate,
eps_actual = excluded.eps_actual,
eps_surprise = excluded.eps_surprise,
eps_surprise_pct = excluded.eps_surprise_pct,
revenue_estimate = excluded.revenue_estimate,
revenue_actual = excluded.revenue_actual,
revenue_surprise = excluded.revenue_surprise,
scrape_date = excluded.scrape_date
`, stock.ID, e.EventDate, e.FiscalYear, e.FiscalQuarter,
e.EPSEstimate, e.EPSActual, e.EPSSurprise, e.EPSSurprisePct,
e.RevenueEstimate, e.RevenueActual, e.RevenueSurprise, today)
if err != nil {
return err
}
}
return nil
}
func (s *StockDB) saveCharts(tx *sql.Tx, stock *StockData, today string) error {
for timeframe, points := range stock.Charts {
for _, p := range points {
_, err := tx.Exec(`
INSERT INTO charts (stock_id, timeframe, timestamp, open, high, low, close, volume, scrape_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, timeframe, timestamp) DO UPDATE SET
open = excluded.open,
high = excluded.high,
low = excluded.low,
close = excluded.close,
volume = excluded.volume,
scrape_date = excluded.scrape_date
`, stock.ID, timeframe, p.Time, p.Open, p.High, p.Low, p.Close, p.Volume, today)
if err != nil {
return err
}
}
}
return nil
}
func (s *StockDB) saveNews(tx *sql.Tx, stock *StockData, today string) error {
for _, n := range stock.News {
var sourceID, sourceName, imageURL string
var imageWidth, imageHeight int
if n.Provider != nil {
sourceID = n.Provider.ID
sourceName = n.Provider.Name
}
if len(n.Images) > 0 {
imageURL = n.Images[0].URL
imageWidth = n.Images[0].Width
imageHeight = n.Images[0].Height
}
// Categorize and score sentiment
category := categorizeNews(n.Title, n.Description)
sentiment, sentimentScore := scoreNewsSentiment(n.Title, n.Description)
isCritical := isNewsCritical(n.Title, n.Description)
_, err := tx.Exec(`
INSERT INTO news (id, stock_id, title, url, abstract, source_id, source_name,
published_at, read_time_min, image_url, image_width, image_height, news_type,
category, sentiment, sentiment_score, is_critical, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
abstract = excluded.abstract,
category = excluded.category,
sentiment = excluded.sentiment,
sentiment_score = excluded.sentiment_score,
is_critical = excluded.is_critical
`, n.ID, stock.ID, n.Title, n.URL, n.Description, sourceID, sourceName,
n.PublishTime, n.ReadTimeMin, imageURL, imageWidth, imageHeight, n.Type,
category, sentiment, sentimentScore, isCritical, today)
if err != nil {
return err
}
}
return nil
}
func (s *StockDB) saveSentiment(tx *sql.Tx, stock *StockData, today string) error {
for _, stat := range stock.Sentiment.SentimentStatistics {
_, err := tx.Exec(`
INSERT INTO sentiment_history (stock_id, scrape_date, time_range, time_range_enum,
start_time, end_time, bullish, bearish, neutral, bullish_pct, bearish_pct, neutral_pct, scenario)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, scrape_date, time_range_enum) DO UPDATE SET
time_range = excluded.time_range,
start_time = excluded.start_time,
end_time = excluded.end_time,
bullish = excluded.bullish,
bearish = excluded.bearish,
neutral = excluded.neutral,
bullish_pct = excluded.bullish_pct,
bearish_pct = excluded.bearish_pct,
neutral_pct = excluded.neutral_pct,
scenario = excluded.scenario
`, stock.ID, today, stat.TimeRangeName, stat.TimeRangeEnum,
stat.StartTime, stat.EndTime, stat.Bullish, stat.Bearish, stat.Neutral,
stat.BullishPercent, stat.BearishPercent, stat.NeutralPercent, stat.Scenario)
if err != nil {
return err
}
}
return nil
}
func (s *StockDB) saveInsights(tx *sql.Tx, stock *StockData, today string) error {
highlightsJSON, _ := json.Marshal(stock.Insights.Highlights)
risksJSON, _ := json.Marshal(stock.Insights.Risks)
_, err := tx.Exec(`
INSERT INTO insights (stock_id, scrape_date, summary, highlights_json, risks_json, last_updated)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(stock_id, scrape_date) DO UPDATE SET
summary = excluded.summary,
highlights_json = excluded.highlights_json,
risks_json = excluded.risks_json,
last_updated = excluded.last_updated
`, stock.ID, today, stock.Insights.Summary, string(highlightsJSON), string(risksJSON), stock.Insights.LastUpdated)
return err
}
func (s *StockDB) saveOwnership(tx *sql.Tx, stock *StockData, today string) error {
saveHolders := func(holders []Holder, holderType string) error {
for _, h := range holders {
_, err := tx.Exec(`
INSERT INTO ownership (stock_id, scrape_date, holder_type, investor_name, investor_type,
shares_held, shares_change, shares_pct, value, report_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, stock.ID, today, holderType, h.Name, h.Type,
h.SharesHeld, h.SharesChange, h.SharesPct, h.Value, h.ReportDate)
if err != nil {
return err
}
}
return nil
}
if stock.Ownership.TopHolders != nil {
if err := saveHolders(stock.Ownership.TopHolders, "top_holders"); err != nil {
return err
}
}
if stock.Ownership.TopBuyers != nil {
if err := saveHolders(stock.Ownership.TopBuyers, "top_buyers"); err != nil {
return err
}
}
if stock.Ownership.TopSellers != nil {
if err := saveHolders(stock.Ownership.TopSellers, "top_sellers"); err != nil {
return err
}
}
if stock.Ownership.NewHolders != nil {
if err := saveHolders(stock.Ownership.NewHolders, "new_holders"); err != nil {
return err
}
}
if stock.Ownership.ExitedHolders != nil {
if err := saveHolders(stock.Ownership.ExitedHolders, "exited_holders"); err != nil {
return err
}
}
return nil
}
// GetStaleStocks returns stocks not seen in the last N days
func (s *StockDB) GetStaleStocks(days int) ([]string, error) {
cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
rows, err := s.db.Query(`
SELECT ticker FROM stocks
WHERE last_seen < ? OR last_seen IS NULL
ORDER BY ticker
`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var tickers []string
for rows.Next() {
var ticker string
if err := rows.Scan(&ticker); err != nil {
return nil, err
}
tickers = append(tickers, ticker)
}
return tickers, nil
}
// GetRunStats returns statistics for a run
func (s *StockDB) GetRunStats(runID int64) (pending, processing, success, failed int, err error) {
err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'pending'`, runID).Scan(&pending)
if err != nil {
return
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'processing'`, runID).Scan(&processing)
if err != nil {
return
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'success'`, runID).Scan(&success)
if err != nil {
return
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'failed'`, runID).Scan(&failed)
return
}