idx-cli/scripts/export_history.py

626 lines
24 KiB
Python

#!/usr/bin/env python3
"""
History Excel Export Script - Professional Edition
Exports historical data (price history, ratio history, sentiment history) to Excel
with proper tables and conditional formatting. No charts - tables only.
Usage:
uv run python scripts/export_history.py --db output/stocks.db --output output/history.xlsx
"""
import argparse
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional
import xlsxwriter
from xlsxwriter.utility import xl_range
# Color palette - matches export_dashboard.py
COLORS = {
'primary_dark': '#1F4E79',
'primary': '#2E75B6',
'primary_light': '#5B9BD5',
'success_dark': '#375623',
'success': '#70AD47',
'success_light': '#C6EFCE',
'success_text': '#006100',
'danger_dark': '#833C0C',
'danger': '#C00000',
'danger_light': '#FFC7CE',
'danger_text': '#9C0006',
'warning_dark': '#7F6000',
'warning': '#FFC000',
'warning_light': '#FFEB9C',
'warning_text': '#9C5700',
'white': '#FFFFFF',
'light_gray': '#F2F2F2',
'dark_gray': '#404040',
}
TABLE_STYLE = 'Table Style Medium 2'
def safe_float(value, default=None) -> Optional[float]:
"""Safely convert to float."""
if value is None:
return default
try:
return float(value)
except (ValueError, TypeError):
return default
class HistoryExporter:
"""Professional History Excel Exporter."""
def __init__(self, workbook: xlsxwriter.Workbook, conn: sqlite3.Connection,
start_date: str = None, end_date: str = None, stock_id: str = None):
self.wb = workbook
self.conn = conn
self.start_date = start_date
self.end_date = end_date
self.stock_id = stock_id
self._setup_formats()
def _setup_formats(self):
"""Setup formatting styles - matches export_dashboard.py."""
self.fmt_title = self.wb.add_format({
'bold': True, 'font_size': 18, 'font_color': COLORS['primary_dark'],
'bottom': 2, 'bottom_color': COLORS['primary_dark']
})
self.fmt_subtitle = self.wb.add_format({
'bold': True, 'font_size': 12, 'font_color': COLORS['primary']
})
self.fmt_section = self.wb.add_format({
'bold': True, 'font_size': 14, 'font_color': COLORS['white'],
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter'
})
self.fmt_header = self.wb.add_format({
'bold': True, 'font_color': COLORS['white'],
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter',
'border': 1, 'text_wrap': True
})
self.fmt_num = self.wb.add_format({'num_format': '#,##0.00', 'align': 'right'})
self.fmt_num_0 = self.wb.add_format({'num_format': '#,##0', 'align': 'right'})
self.fmt_pct = self.wb.add_format({'num_format': '0.00"%"', 'align': 'right'})
self.fmt_pct_signed = self.wb.add_format({'num_format': '+0.00%;-0.00%;0.00%', 'align': 'right'})
self.fmt_date = self.wb.add_format({'num_format': 'yyyy-mm-dd', 'align': 'center'})
self.fmt_kpi_value = self.wb.add_format({
'bold': True, 'font_size': 24, 'font_color': COLORS['primary_dark'],
'align': 'center', 'valign': 'vcenter'
})
self.fmt_kpi_label = self.wb.add_format({
'font_size': 10, 'font_color': COLORS['dark_gray'],
'align': 'center', 'valign': 'vcenter', 'bold': True
})
self.fmt_kpi_box = self.wb.add_format({
'bg_color': COLORS['light_gray'], 'border': 1, 'border_color': COLORS['primary_light']
})
def _add_table(self, ws, start_row: int, start_col: int, data: list,
columns: list, table_name: str, total_row: bool = False) -> int:
"""Add a proper Excel Table with filtering and sorting."""
if not data:
ws.write(start_row, start_col, "No data available")
return start_row + 1
end_row = start_row + len(data)
end_col = start_col + len(columns) - 1
# Build table columns
table_columns = []
for col_def in columns:
col_opt = {'header': col_def['header']}
if col_def.get('total_function'):
col_opt['total_function'] = col_def['total_function']
if col_def.get('total_string'):
col_opt['total_string'] = col_def['total_string']
if col_def.get('format'):
col_opt['format'] = col_def['format']
table_columns.append(col_opt)
# Write data
for row_idx, row_data in enumerate(data):
for col_idx, col_def in enumerate(columns):
key = col_def.get('key')
value = row_data.get(key) if key else None
transform = col_def.get('transform')
if transform and value is not None:
value = transform(value)
fmt = col_def.get('format')
ws.write(start_row + 1 + row_idx, start_col + col_idx, value, fmt)
# Add table
table_range = xl_range(start_row, start_col, end_row, end_col)
ws.add_table(table_range, {
'name': table_name,
'style': TABLE_STYLE,
'columns': table_columns,
'total_row': total_row,
'autofilter': True,
})
# Set column widths
for col_idx, col_def in enumerate(columns):
width = col_def.get('width', 12)
ws.set_column(start_col + col_idx, start_col + col_idx, width)
return end_row + (2 if total_row else 1)
def _get_stocks(self) -> list:
"""Get all stocks."""
cursor = self.conn.execute("""
SELECT id, ticker, name, exchange_code as exchange
FROM stocks ORDER BY ticker
""")
return [dict(row) for row in cursor.fetchall()]
def _get_price_history(self) -> list:
"""Get price history with filters."""
query = """
SELECT ph.*, s.ticker, s.name
FROM price_history ph
JOIN stocks s ON ph.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND ph.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND ph.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND ph.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, ph.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_ratios_history(self) -> list:
"""Get ratios history with filters."""
query = """
SELECT rh.*, s.ticker, s.name
FROM ratios_history rh
JOIN stocks s ON rh.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND rh.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND rh.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND rh.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, rh.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_sentiment_history(self) -> list:
"""Get sentiment history with filters."""
query = """
SELECT sh.*, s.ticker, s.name
FROM sentiment_history sh
JOIN stocks s ON sh.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND sh.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND sh.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND sh.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, sh.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_scrape_runs(self) -> list:
"""Get scrape runs."""
cursor = self.conn.execute("""
SELECT * FROM scrape_runs ORDER BY started_at DESC
""")
return [dict(row) for row in cursor.fetchall()]
def create_summary_sheet(self, stocks: list, price_history: list,
ratios_history: list, sentiment_history: list,
scrape_runs: list):
"""Create summary sheet."""
ws = self.wb.add_worksheet("Summary")
# Title
ws.merge_range('A1:F1', "Historical Data Summary", self.fmt_title)
ws.set_row(0, 30)
ws.write('A2', f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", self.fmt_subtitle)
# KPI cards
row = 4
kpis = [
("Total Stocks", str(len(stocks))),
("Price Records", str(len(price_history))),
("Ratio Records", str(len(ratios_history))),
("Sentiment Records", str(len(sentiment_history))),
("Scrape Runs", str(len(scrape_runs))),
]
col = 0
for label, value in kpis:
ws.merge_range(row, col, row + 1, col + 1, '', self.fmt_kpi_box)
ws.write(row, col, label, self.fmt_kpi_label)
ws.write(row + 1, col, value, self.fmt_kpi_value)
col += 2
# Date range
row = 7
ws.write(row, 0, "Data Date Range:", self.fmt_subtitle)
if price_history:
dates = [p['scrape_date'] for p in price_history if p.get('scrape_date')]
if dates:
ws.write(row + 1, 0, f"From: {min(dates)}")
ws.write(row + 2, 0, f"To: {max(dates)}")
# Scrape runs summary table
row = 11
ws.merge_range(row, 0, row, 5, "Recent Scrape Runs", self.fmt_section)
row += 1
run_data = []
for r in scrape_runs[:10]:
started = r.get('started_at', '')
finished = r.get('finished_at', '')
duration = ''
if started and finished:
try:
start_dt = datetime.fromisoformat(started.replace('Z', '+00:00'))
finish_dt = datetime.fromisoformat(finished.replace('Z', '+00:00'))
delta = finish_dt - start_dt
duration = str(delta)
except:
pass
run_data.append({
'id': r.get('id'),
'status': r.get('status'),
'index': r.get('index_name'),
'total': r.get('total_stocks'),
'success': r.get('success'),
'failed': r.get('failed'),
'duration': duration,
})
run_cols = [
{'header': 'Run ID', 'key': 'id', 'width': 8},
{'header': 'Status', 'key': 'status', 'width': 12},
{'header': 'Index', 'key': 'index', 'width': 10},
{'header': 'Total', 'key': 'total', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Success', 'key': 'success', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Failed', 'key': 'failed', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Duration', 'key': 'duration', 'width': 15},
]
self._add_table(ws, row, 0, run_data, run_cols, 'ScrapeRuns')
ws.set_column('A:G', 12)
def create_price_history_sheet(self, data: list):
"""Create price history sheet."""
ws = self.wb.add_worksheet("Price History")
ws.merge_range('A1:N1', "Price History", self.fmt_title)
ws.set_row(0, 25)
row = 3
price_data = [{
'ticker': p['ticker'],
'name': p['name'],
'date': p.get('scrape_date', ''),
'price': safe_float(p.get('price')),
'change': safe_float(p.get('price_change')),
'change_pct': safe_float(p.get('price_change_pct'), 0) / 100 if p.get('price_change_pct') else None,
'open': safe_float(p.get('price_open')),
'high': safe_float(p.get('price_high')),
'low': safe_float(p.get('price_low')),
'volume': safe_float(p.get('volume')),
'market_cap': safe_float(p.get('market_cap')),
'high_52w': safe_float(p.get('price_52w_high')),
'low_52w': safe_float(p.get('price_52w_low')),
'ytd_pct': safe_float(p.get('price_change_ytd'), 0) / 100 if p.get('price_change_ytd') else None,
} for p in data]
price_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 22},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_num},
{'header': 'Change', 'key': 'change', 'width': 10, 'format': self.fmt_num},
{'header': 'Chg%', 'key': 'change_pct', 'width': 8, 'format': self.fmt_pct_signed},
{'header': 'Open', 'key': 'open', 'width': 10, 'format': self.fmt_num},
{'header': 'High', 'key': 'high', 'width': 10, 'format': self.fmt_num},
{'header': 'Low', 'key': 'low', 'width': 10, 'format': self.fmt_num},
{'header': 'Volume', 'key': 'volume', 'width': 14, 'format': self.fmt_num_0},
{'header': 'Market Cap', 'key': 'market_cap', 'width': 15, 'format': self.fmt_num_0},
{'header': '52W High', 'key': 'high_52w', 'width': 10, 'format': self.fmt_num},
{'header': '52W Low', 'key': 'low_52w', 'width': 10, 'format': self.fmt_num},
{'header': 'YTD%', 'key': 'ytd_pct', 'width': 8, 'format': self.fmt_pct_signed},
]
self._add_table(ws, row, 0, price_data, price_cols, 'PriceHistory')
# Conditional formatting on change %
if price_data:
data_end = row + len(price_data)
ws.conditional_format(row + 1, 5, data_end, 5, {
'type': '3_color_scale',
'min_color': '#F8696B',
'mid_color': '#FFEB84',
'max_color': '#63BE7B',
})
ws.freeze_panes(4, 2)
def create_ratios_history_sheet(self, data: list):
"""Create ratios history sheet."""
ws = self.wb.add_worksheet("Ratios History")
ws.merge_range('A1:W1', "Financial Ratios History", self.fmt_title)
ws.set_row(0, 25)
row = 3
ratio_data = [{
'ticker': r['ticker'],
'name': r['name'],
'date': r.get('scrape_date', ''),
'year': r.get('year', ''),
'pe': safe_float(r.get('pe_ratio')),
'pb': safe_float(r.get('pb_ratio')),
'ps': safe_float(r.get('ps_ratio')),
'pcf': safe_float(r.get('pcf_ratio')),
'ev_ebitda': safe_float(r.get('ev_ebitda')),
'roe': safe_float(r.get('roe')),
'roa': safe_float(r.get('roa')),
'roic': safe_float(r.get('roic')),
'gross': safe_float(r.get('gross_margin')),
'op_margin': safe_float(r.get('operating_margin')),
'net_margin': safe_float(r.get('net_margin')),
'de': safe_float(r.get('debt_to_equity')),
'current': safe_float(r.get('current_ratio')),
'quick': safe_float(r.get('quick_ratio')),
'div_yield': safe_float(r.get('dividend_yield')),
'payout': safe_float(r.get('payout_ratio')),
'eps': safe_float(r.get('eps')),
'bvps': safe_float(r.get('bvps')),
'rev_gr': safe_float(r.get('revenue_growth')),
} for r in data]
ratio_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 20},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Year', 'key': 'year', 'width': 6},
{'header': 'P/E', 'key': 'pe', 'width': 7, 'format': self.fmt_num},
{'header': 'P/B', 'key': 'pb', 'width': 7, 'format': self.fmt_num},
{'header': 'P/S', 'key': 'ps', 'width': 7, 'format': self.fmt_num},
{'header': 'P/CF', 'key': 'pcf', 'width': 7, 'format': self.fmt_num},
{'header': 'EV/EBITDA', 'key': 'ev_ebitda', 'width': 9, 'format': self.fmt_num},
{'header': 'ROE%', 'key': 'roe', 'width': 7, 'format': self.fmt_num},
{'header': 'ROA%', 'key': 'roa', 'width': 7, 'format': self.fmt_num},
{'header': 'ROIC%', 'key': 'roic', 'width': 7, 'format': self.fmt_num},
{'header': 'Gross%', 'key': 'gross', 'width': 8, 'format': self.fmt_num},
{'header': 'Op%', 'key': 'op_margin', 'width': 7, 'format': self.fmt_num},
{'header': 'Net%', 'key': 'net_margin', 'width': 7, 'format': self.fmt_num},
{'header': 'D/E', 'key': 'de', 'width': 7, 'format': self.fmt_num},
{'header': 'Current', 'key': 'current', 'width': 8, 'format': self.fmt_num},
{'header': 'Quick', 'key': 'quick', 'width': 7, 'format': self.fmt_num},
{'header': 'Yield%', 'key': 'div_yield', 'width': 7, 'format': self.fmt_num},
{'header': 'Payout%', 'key': 'payout', 'width': 8, 'format': self.fmt_num},
{'header': 'EPS', 'key': 'eps', 'width': 8, 'format': self.fmt_num},
{'header': 'BVPS', 'key': 'bvps', 'width': 9, 'format': self.fmt_num},
{'header': 'RevGr%', 'key': 'rev_gr', 'width': 8, 'format': self.fmt_num},
]
self._add_table(ws, row, 0, ratio_data, ratio_cols, 'RatiosHistory')
# Conditional formatting on ROE
if ratio_data:
data_end = row + len(ratio_data)
ws.conditional_format(row + 1, 9, data_end, 9, {
'type': 'data_bar',
'bar_color': COLORS['success'],
'bar_solid': True,
})
ws.freeze_panes(4, 2)
def create_sentiment_history_sheet(self, data: list):
"""Create sentiment history sheet."""
ws = self.wb.add_worksheet("Sentiment History")
ws.merge_range('A1:K1', "Sentiment History", self.fmt_title)
ws.set_row(0, 25)
row = 3
sent_data = [{
'ticker': s['ticker'],
'name': s['name'],
'date': s.get('scrape_date', ''),
'time_range': s.get('time_range', ''),
'bullish': safe_float(s.get('bullish_pct')),
'bearish': safe_float(s.get('bearish_pct')),
'neutral': safe_float(s.get('neutral_pct')),
'bull_count': safe_float(s.get('bullish')),
'bear_count': safe_float(s.get('bearish')),
'neut_count': safe_float(s.get('neutral')),
'net': (safe_float(s.get('bullish_pct'), 0) - safe_float(s.get('bearish_pct'), 0)),
} for s in data]
sent_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 22},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Period', 'key': 'time_range', 'width': 15},
{'header': 'Bull%', 'key': 'bullish', 'width': 8, 'format': self.fmt_num},
{'header': 'Bear%', 'key': 'bearish', 'width': 8, 'format': self.fmt_num},
{'header': 'Neut%', 'key': 'neutral', 'width': 8, 'format': self.fmt_num},
{'header': 'Bulls', 'key': 'bull_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Bears', 'key': 'bear_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Neutral', 'key': 'neut_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Net', 'key': 'net', 'width': 8, 'format': self.fmt_num},
]
self._add_table(ws, row, 0, sent_data, sent_cols, 'SentimentHistory')
# Conditional formatting
if sent_data:
data_end = row + len(sent_data)
# Bullish data bar
ws.conditional_format(row + 1, 4, data_end, 4, {
'type': 'data_bar',
'bar_color': COLORS['success'],
'bar_solid': True,
})
# Bearish data bar
ws.conditional_format(row + 1, 5, data_end, 5, {
'type': 'data_bar',
'bar_color': COLORS['danger'],
'bar_solid': True,
})
# Net sentiment 3-color
ws.conditional_format(row + 1, 10, data_end, 10, {
'type': '3_color_scale',
'min_color': '#F8696B',
'mid_color': '#FFEB84',
'max_color': '#63BE7B',
})
ws.freeze_panes(4, 2)
def create_price_pivot_sheet(self, stocks: list, price_history: list):
"""Create price pivot table (dates as rows, stocks as columns)."""
ws = self.wb.add_worksheet("Price Pivot")
ws.merge_range('A1:C1', "Price Matrix (Pivot)", self.fmt_title)
ws.set_row(0, 25)
if not price_history or not stocks:
ws.write(3, 0, "No data available")
return
# Get unique dates (limit to 365)
dates = sorted(set(p['scrape_date'] for p in price_history if p.get('scrape_date')),
reverse=True)[:365]
if not dates:
ws.write(3, 0, "No date data available")
return
# Limit to 100 stocks
stock_list = stocks[:100]
# Build price lookup
price_lookup = {}
for p in price_history:
key = (p['stock_id'], p['scrape_date'])
price_lookup[key] = p.get('price')
# Header row with tickers
row = 3
ws.write(row, 0, "Date", self.fmt_header)
for col, stock in enumerate(stock_list, 1):
ws.write(row, col, stock['ticker'], self.fmt_header)
# Data rows
for row_idx, date in enumerate(dates):
ws.write(row + 1 + row_idx, 0, date)
for col_idx, stock in enumerate(stock_list, 1):
price = price_lookup.get((stock['id'], date))
if price:
ws.write(row + 1 + row_idx, col_idx, price, self.fmt_num)
ws.freeze_panes(4, 1)
ws.set_column(0, 0, 12)
ws.set_column(1, 100, 10)
def generate(self):
"""Generate all history sheets."""
print("Loading data...")
stocks = self._get_stocks()
price_history = self._get_price_history()
ratios_history = self._get_ratios_history()
sentiment_history = self._get_sentiment_history()
scrape_runs = self._get_scrape_runs()
print("Creating Summary sheet...")
self.create_summary_sheet(stocks, price_history, ratios_history,
sentiment_history, scrape_runs)
print("Creating Price History sheet...")
self.create_price_history_sheet(price_history)
print("Creating Ratios History sheet...")
self.create_ratios_history_sheet(ratios_history)
print("Creating Sentiment History sheet...")
self.create_sentiment_history_sheet(sentiment_history)
if stocks and price_history:
print("Creating Price Pivot sheet...")
self.create_price_pivot_sheet(stocks, price_history)
def main():
parser = argparse.ArgumentParser(
description="Export historical stock data to professional Excel"
)
parser.add_argument('--db', required=True, help='Path to SQLite database')
parser.add_argument('--output', '-o', required=True, help='Output Excel file path')
parser.add_argument('--start-date', help='Start date filter (YYYY-MM-DD)')
parser.add_argument('--end-date', help='End date filter (YYYY-MM-DD)')
parser.add_argument('--stock', help='Filter by stock ID')
args = parser.parse_args()
if not Path(args.db).exists():
print(f"Error: Database not found: {args.db}")
return 1
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
workbook = xlsxwriter.Workbook(args.output, {
'constant_memory': False,
'strings_to_urls': True,
})
exporter = HistoryExporter(
workbook, conn,
start_date=args.start_date,
end_date=args.end_date,
stock_id=args.stock
)
exporter.generate()
workbook.close()
conn.close()
print(f"\nHistory exported to: {args.output}")
return 0
if __name__ == "__main__":
exit(main())