OptETF Deploy
Deploy OptETF web app (cache-only on server, cross-platform paths, tz+dedup fixes) for HF Spaces
e8ccbfa | """End-to-end builder for sentiment news events, FinBERT scores, daily cache, and plots.""" | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| import matplotlib.pyplot as plt | |
| import pandas as pd | |
| from .config import ( | |
| DAILY_SENTIMENT_CACHE, | |
| DATA_DIR, | |
| DEFAULT_HALF_LIFE_DAYS, | |
| DEFAULT_LOOKBACK_DAYS, | |
| NEWS_EVENTS_CACHE, | |
| ) | |
| from .daily_builder import build_daily_sentiment | |
| from .finbert_scoring import score_news_events | |
| from .finnhub_fetcher import fetch_company_news, normalize_finnhub_articles | |
| from .store import load_news_events, save_daily_sentiment, save_news_events | |
| PLOTS_DIR = Path(__file__).resolve().parent / "plots" | |
| def _load_finnhub_key(cli_key: str | None = None) -> str: | |
| """優先使用 CLI/env key;若沒有,再嘗試讀取專案原本 parameters.py 的 Finnhub key。""" | |
| if cli_key: | |
| return cli_key | |
| env_key = os.getenv("FINNHUB_API_KEY") | |
| if env_key: | |
| return env_key | |
| try: | |
| import parameters | |
| key = getattr(parameters, "FINNHUB_API_KEY", "") | |
| if key and "請填入" not in key and "Finnhub_API_Key" not in key: | |
| return key | |
| except Exception: | |
| pass | |
| raise ValueError("Cannot find Finnhub API key. Pass --api-key or set FINNHUB_API_KEY.") | |
| def _date_chunks(start_date: str, end_date: str, chunk_days: int) -> list[tuple[str, str]]: | |
| """把長區間切成多個 API 查詢區間,降低單次請求失敗風險。""" | |
| start = pd.Timestamp(start_date).normalize() | |
| end = pd.Timestamp(end_date).normalize() | |
| if start > end: | |
| raise ValueError("start_date must be before end_date.") | |
| chunks = [] | |
| cursor = start | |
| while cursor <= end: | |
| chunk_end = min(cursor + pd.Timedelta(days=chunk_days - 1), end) | |
| chunks.append((cursor.strftime("%Y-%m-%d"), chunk_end.strftime("%Y-%m-%d"))) | |
| cursor = chunk_end + pd.Timedelta(days=1) | |
| return chunks | |
| def fetch_news_events_for_tickers( | |
| tickers: list[str], | |
| start_date: str, | |
| end_date: str, | |
| api_key: str, | |
| chunk_days: int, | |
| sleep_seconds: float, | |
| ) -> pd.DataFrame: | |
| """抓取多個 ticker 的新聞事件,回傳符合 news_events_cache schema 的表格。""" | |
| all_parts = [] | |
| chunks = _date_chunks(start_date, end_date, chunk_days) | |
| for ticker in tickers: | |
| for chunk_start, chunk_end in chunks: | |
| print(f"[FETCH] {ticker} {chunk_start} -> {chunk_end}") | |
| try: | |
| articles = fetch_company_news(ticker, chunk_start, chunk_end, api_key) | |
| if articles: | |
| all_parts.append(normalize_finnhub_articles(ticker, articles)) | |
| print(f" articles={len(articles)}") | |
| if len(articles) >= 240: | |
| print( | |
| " warning: article count is close to Finnhub response cap; " | |
| "use a smaller --chunk-days to avoid truncation." | |
| ) | |
| else: | |
| print(" articles=0") | |
| except Exception as exc: | |
| print(f" failed: {exc}") | |
| if sleep_seconds > 0: | |
| time.sleep(sleep_seconds) | |
| if not all_parts: | |
| return pd.DataFrame() | |
| return pd.concat(all_parts, ignore_index=True) | |
| def plot_ticker_sentiment( | |
| daily_df: pd.DataFrame, | |
| ticker: str, | |
| output_dir: Path = PLOTS_DIR, | |
| output_name: str | None = None, | |
| ) -> Path: | |
| """畫出單一 ticker 的每日 sentiment 變化,用來檢查 cache 是否合理。""" | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| ticker_df = daily_df[daily_df["ticker"] == ticker].copy() | |
| if ticker_df.empty: | |
| raise ValueError(f"No daily sentiment rows for ticker: {ticker}") | |
| ticker_df["date"] = pd.to_datetime(ticker_df["date"], errors="coerce") | |
| ticker_df["sentiment_score"] = pd.to_numeric(ticker_df["sentiment_score"], errors="coerce") | |
| ticker_df["news_count"] = pd.to_numeric(ticker_df["news_count"], errors="coerce").fillna(0) | |
| ticker_df = ticker_df.dropna(subset=["date", "sentiment_score"]).sort_values("date") | |
| fig, ax = plt.subplots(figsize=(13, 6)) | |
| ax.plot( | |
| ticker_df["date"], | |
| ticker_df["sentiment_score"], | |
| color="#2563EB", | |
| linewidth=1.8, | |
| label="Daily Decayed Sentiment", | |
| ) | |
| ax.axhline(0, color="#111827", linewidth=1, linestyle="--", alpha=0.7) | |
| ax.fill_between( | |
| ticker_df["date"], | |
| ticker_df["sentiment_score"], | |
| 0, | |
| where=ticker_df["sentiment_score"] >= 0, | |
| color="#22C55E", | |
| alpha=0.18, | |
| interpolate=True, | |
| ) | |
| ax.fill_between( | |
| ticker_df["date"], | |
| ticker_df["sentiment_score"], | |
| 0, | |
| where=ticker_df["sentiment_score"] < 0, | |
| color="#EF4444", | |
| alpha=0.18, | |
| interpolate=True, | |
| ) | |
| ax.set_title(f"{ticker} Market Sentiment (FinBERT, 180-day Exponential Decay)", fontsize=14, fontweight="bold") | |
| ax.set_xlabel("Date") | |
| ax.set_ylabel("Sentiment Score") | |
| ax.set_ylim(-1.05, 1.05) | |
| ax.grid(True, alpha=0.25) | |
| ax_news = ax.twinx() | |
| ax_news.bar( | |
| ticker_df["date"], | |
| ticker_df["news_count"], | |
| color="#94A3B8", | |
| alpha=0.22, | |
| label="News Count", | |
| width=3, | |
| ) | |
| ax_news.set_ylabel("News Count in 180-day Window") | |
| lines, labels = ax.get_legend_handles_labels() | |
| bars, bar_labels = ax_news.get_legend_handles_labels() | |
| ax.legend(lines + bars, labels + bar_labels, loc="upper left") | |
| fig.tight_layout() | |
| output_path = output_dir / (output_name or f"{ticker}_sentiment.png") | |
| fig.savefig(output_path, dpi=300) | |
| plt.close(fig) | |
| return output_path | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Fetch, score, build, and plot sentiment cache.") | |
| parser.add_argument("--tickers", default="VOO", help="Comma-separated tickers, e.g. VOO,VT.") | |
| parser.add_argument("--start-date", required=True) | |
| parser.add_argument("--end-date", default=datetime.today().strftime("%Y-%m-%d")) | |
| parser.add_argument("--api-key", default=None) | |
| parser.add_argument("--chunk-days", type=int, default=7) | |
| parser.add_argument("--sleep-seconds", type=float, default=1.0) | |
| parser.add_argument("--lookback-days", type=int, default=DEFAULT_LOOKBACK_DAYS) | |
| parser.add_argument("--half-life-days", type=int, default=DEFAULT_HALF_LIFE_DAYS) | |
| parser.add_argument("--plot-ticker", default="VOO") | |
| parser.add_argument("--skip-fetch", action="store_true") | |
| parser.add_argument("--skip-score", action="store_true") | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| tickers = [ticker.strip().upper() for ticker in args.tickers.split(",") if ticker.strip()] | |
| existing_events = load_news_events(NEWS_EVENTS_CACHE) | |
| if args.skip_fetch: | |
| merged_events = existing_events | |
| else: | |
| api_key = _load_finnhub_key(args.api_key) | |
| fetched_events = fetch_news_events_for_tickers( | |
| tickers, | |
| args.start_date, | |
| args.end_date, | |
| api_key, | |
| args.chunk_days, | |
| args.sleep_seconds, | |
| ) | |
| if existing_events.empty: | |
| merged_events = fetched_events | |
| elif fetched_events.empty: | |
| merged_events = existing_events | |
| else: | |
| merged_events = pd.concat([existing_events, fetched_events], ignore_index=True) | |
| save_news_events(merged_events, NEWS_EVENTS_CACHE) | |
| merged_events = load_news_events(NEWS_EVENTS_CACHE) | |
| print(f"[CACHE] news_events rows={len(merged_events)} path={NEWS_EVENTS_CACHE}") | |
| if not args.skip_score: | |
| scored_events = score_news_events(merged_events) | |
| save_news_events(scored_events, NEWS_EVENTS_CACHE) | |
| merged_events = load_news_events(NEWS_EVENTS_CACHE) | |
| scored_count = pd.to_numeric(merged_events["finbert_score"], errors="coerce").notna().sum() | |
| print(f"[SCORE] scored rows={scored_count}/{len(merged_events)}") | |
| daily = build_daily_sentiment( | |
| merged_events, | |
| tickers=tickers, | |
| start_date=args.start_date, | |
| end_date=args.end_date, | |
| lookback_days=args.lookback_days, | |
| half_life_days=args.half_life_days, | |
| ) | |
| save_daily_sentiment(daily, DAILY_SENTIMENT_CACHE) | |
| print(f"[DAILY] rows={len(daily)} path={DAILY_SENTIMENT_CACHE}") | |
| if args.plot_ticker: | |
| output_path = plot_ticker_sentiment(daily, args.plot_ticker.upper()) | |
| print(f"[PLOT] {output_path}") | |
| plot_ticker = args.plot_ticker.upper() | |
| plot_df = daily[daily["ticker"] == plot_ticker].copy() | |
| plot_df["news_count"] = pd.to_numeric(plot_df["news_count"], errors="coerce").fillna(0) | |
| plot_df["date"] = pd.to_datetime(plot_df["date"], errors="coerce") | |
| nonzero = plot_df[plot_df["news_count"] > 0] | |
| if not nonzero.empty: | |
| zoom_start = (nonzero["date"].min() - pd.Timedelta(days=30)).strftime("%Y-%m-%d") | |
| zoom_df = plot_df[plot_df["date"] >= zoom_start] | |
| zoom_output = plot_ticker_sentiment( | |
| zoom_df, | |
| plot_ticker, | |
| PLOTS_DIR, | |
| output_name=f"{plot_ticker}_sentiment_zoom.png", | |
| ) | |
| print(f"[PLOT] {zoom_output}") | |
| if __name__ == "__main__": | |
| main() | |