OptETF Deploy
Deploy OptETF web app (cache-only on server, cross-platform paths, tz+dedup fixes) for HF Spaces
e8ccbfa
Raw
History Blame Contribute Delete
9.49 kB
"""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()