diff --git a/earnings_trend_model/README.md b/earnings_trend_model/README.md new file mode 100644 index 0000000..97af6f4 --- /dev/null +++ b/earnings_trend_model/README.md @@ -0,0 +1,81 @@ +# Earnings Profit → 3–5 Day Trend Model / 财报利润 → 3–5 天趋势模型 + +用**财报利润数据**预测个股在财报公布后 **3–5 个交易日**的价格走势(post-earnings drift,财报漂移)。 + +## 这是什么 + +给每一次历史财报事件构造一组“利润特征”,打上“未来 3/5 天涨跌”的标签, +在按时间排序的事件表上训练一个方向分类器 + 一个幅度回归器,并对最新财报打分。 + +- **利润特征**:EPS 惊喜度(%)、营收同比、净利润同比、净利率及其同比变化、财报前 5/20 日动量 +- **标签**:入场日(公告当天或之后第一个交易日)起 3 日 / 5 日的前向收益与涨跌方向 +- **模型**:`RandomForestClassifier`(方向)+ `Ridge`(幅度),按时间顺序切分训练/测试,避免未来数据泄露 +- **数据源**:`yfinance`,无需 API key + +## 文件 + +| 文件 | 说明 | +|------|------| +| `earnings_trend_model.ipynb` | 主 notebook:一键跑通数据→EDA→建模→打分(Colab 友好) | +| `earnings_trend.py` | 核心可复用模块,供 notebook 和自动化脚本调用 | +| `README.md` | 本文件 | + +## 快速开始 + +```bash +pip install yfinance lxml pandas numpy scikit-learn matplotlib +``` + +```python +import earnings_trend as et + +df = et.build_dataset(["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]) # 拉数据 + 造事件表 +model = et.train_models(df, horizon=5) # 训练 5 日模型 +print("方向准确率:", round(model.test_accuracy, 3), + " vs 基线:", round(model.baseline_accuracy, 3)) +print(et.score_upcoming("AAPL", model)) # 对最新财报打分 +``` + +或直接打开 `earnings_trend_model.ipynb`,改顶部的 `TICKERS` 即可。 + +## 受限网络 / 代理环境 + +`yfinance` 默认用 `curl_cffi` 做浏览器 TLS 指纹;企业或 agent 代理会重置这类连接 +(报 `Connection reset by peer`)。此时改用普通 `requests` 会话: + +```python +import requests, earnings_trend as et +s = requests.Session(); s.headers.update({"User-Agent": "Mozilla/5.0"}) +# 若代理有自签 CA: import os; os.environ["REQUESTS_CA_BUNDLE"] = "/path/ca.crt" +et.set_session(s) +``` + +## 重要限制(务必先读) + +- **免费 yfinance 的利润表只有约 5 个季度**,所以 `net_income_yoy` / `revenue_yoy` 等同比特征 + 对**较早的**财报事件多为 NaN。实测里能稳定驱动模型的是 **EPS 惊喜度 + 财报前动量** + (`get_earnings_dates` 提供约 25 个季度,覆盖完整)。想让基本面同比特征真正生效,需换一个 + 历史更长的利润数据源(见下)。 +- **样本量**:每只股票只有十几个季度财报。想要统计显著性,请把 `TICKERS` 扩到几十、上百只。 + 真实的后财报漂移信号很弱,方向准确率通常就在 50% 附近波动 —— 代码如实汇报,不做粉饰。 +- **财报时点**:盘前/盘后公布会影响入场日。本模型统一取“公告当天或之后第一个交易日”入场, + 衡量随后 3/5 日漂移。 +- 仅用于研究,**不构成投资建议**。 + +## 换用 Robinhood 数据源(可选) + +若已在本会话授权 Robinhood 连接器,可把利润与财报数据换成覆盖更全的接口: + +- `get_earnings_results` → 替换 `get_earnings_dates`(EPS 估计/实际) +- `get_financials` → 替换 `quarterly_income_stmt`(营收、净利润) +- `get_equity_historicals` → 替换 `yf.download`(价格) + +只需在 `earnings_trend.py` 里改 `get_earnings_dates` / `get_quarterly_income` / +`get_price_history` 三个函数的实现,其余特征工程与建模逻辑不变。 + +## 下一步可做 + +- walk-forward / 滚动交叉验证,替代单次时间切分 +- 加入交易成本、滑点、隔夜跳空的象限分析 +- 用分位数分组回测(大幅超预期 vs 大幅不及)的分组收益与胜率 +- 接更长历史的基本面源,激活同比利润特征 diff --git a/earnings_trend_model/earnings_trend.py b/earnings_trend_model/earnings_trend.py new file mode 100644 index 0000000..819c0d4 --- /dev/null +++ b/earnings_trend_model/earnings_trend.py @@ -0,0 +1,372 @@ +""" +Earnings profit -> 3-5 day post-earnings price trend model. + +Core, reusable logic shared by the notebook and any automation. + +Pipeline +-------- +1. For each ticker, pull quarterly earnings events (EPS estimate vs. reported) + and quarterly income-statement lines (revenue, net income). +2. Engineer profit features per earnings event: + - EPS surprise (%) how much profit beat/missed expectations + - Revenue YoY growth (%) top-line trend + - Net-income YoY growth (%) bottom-line (profit) trend + - Net margin & its YoY change profitability quality + - Pre-earnings momentum (5d / 20d) what the tape already priced in +3. Label each event with the forward price trend over the next 3 and 5 + trading days (the post-earnings drift), plus its direction (up/down). +4. Train classifiers (direction) and regressors (magnitude) on the pooled, + time-ordered event table and evaluate out-of-sample. + +Data source is yfinance so the whole thing runs with no API keys. Every +network call is wrapped so a single bad ticker never kills the run. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +# Trading-day horizons for the "3-5 day trend". +HORIZONS = (3, 5) + +# Default universe: liquid large caps with clean, regular earnings history. +DEFAULT_TICKERS = [ + "AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", + "META", "TSLA", "AMD", "NFLX", "JPM", +] + +# Optional HTTP session. In most environments yfinance's default backend is +# fine. Behind a TLS-terminating corporate/agent proxy, curl_cffi's browser +# impersonation can get reset -- call set_session() with a plain requests +# session in that case (see README). +_SESSION = None + + +def set_session(session): + """Route all yfinance calls through a caller-supplied HTTP session.""" + global _SESSION + _SESSION = session + + +FEATURE_COLS = [ + "eps_surprise_pct", + "revenue_yoy", + "net_income_yoy", + "net_margin", + "net_margin_yoy_chg", + "pre_5d_return", + "pre_20d_return", +] + + +# --------------------------------------------------------------------------- # +# Data collection +# --------------------------------------------------------------------------- # +def _safe(fn, default=None): + """Run a yfinance call, swallowing network/parse errors.""" + try: + return fn() + except Exception as exc: # noqa: BLE001 - yfinance raises many things + print(f" ! {exc}") + return default + + +def get_price_history(ticker: str, start=None, end=None) -> pd.DataFrame: + """Daily OHLCV for a ticker as a tz-naive, date-indexed frame.""" + import yfinance as yf + + kw = {"session": _SESSION} if _SESSION is not None else {} + df = _safe( + lambda: yf.download( + ticker, start=start, end=end, progress=False, auto_adjust=True, **kw + ), + default=pd.DataFrame(), + ) + if df is None or df.empty: + return pd.DataFrame() + # yfinance may return a MultiIndex column frame for a single ticker. + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + df.index = pd.to_datetime(df.index).tz_localize(None) + return df + + +def get_earnings_dates(ticker: str, limit: int = 40) -> pd.DataFrame: + """Earnings events with EPS estimate / reported / surprise(%).""" + import yfinance as yf + + tk = yf.Ticker(ticker, session=_SESSION) if _SESSION is not None else yf.Ticker(ticker) + df = _safe(lambda: tk.get_earnings_dates(limit=limit)) + if df is None or df.empty: + return pd.DataFrame() + df = df.copy() + df.index = pd.to_datetime(df.index).tz_localize(None) + df = df.rename( + columns={ + "EPS Estimate": "eps_estimate", + "Reported EPS": "eps_reported", + "Surprise(%)": "surprise_pct", + } + ) + return df.sort_index() + + +def get_quarterly_income(ticker: str) -> pd.DataFrame: + """Quarterly income statement, oriented as date-indexed rows.""" + import yfinance as yf + + tk = yf.Ticker(ticker, session=_SESSION) if _SESSION is not None else yf.Ticker(ticker) + stmt = _safe(lambda: tk.quarterly_income_stmt) + if stmt is None or stmt.empty: + return pd.DataFrame() + df = stmt.T # rows = report dates, columns = line items + df.index = pd.to_datetime(df.index).tz_localize(None) + return df.sort_index() + + +# --------------------------------------------------------------------------- # +# Feature / label engineering +# --------------------------------------------------------------------------- # +def _nearest_income_row(income: pd.DataFrame, when: pd.Timestamp): + """Income-statement quarter whose report date is closest to `when`.""" + if income.empty: + return None, None + diffs = (income.index - when).to_series().abs() + pos = diffs.values.argmin() + if abs((income.index[pos] - when).days) > 100: + return None, None + return income.index[pos], pos + + +def _line(row, *names): + """First matching income-statement line item, else NaN.""" + if row is None: + return np.nan + for n in names: + if n in row.index and pd.notna(row[n]): + return float(row[n]) + return np.nan + + +def build_events(ticker: str) -> pd.DataFrame: + """One row per past earnings event with profit features + forward trend.""" + earnings = get_earnings_dates(ticker) + if earnings.empty: + return pd.DataFrame() + + # Only events that have already been reported. + earnings = earnings.dropna(subset=["eps_reported"]) + if earnings.empty: + return pd.DataFrame() + + prices = get_price_history( + ticker, + start=(earnings.index.min() - pd.Timedelta(days=40)), + end=(earnings.index.max() + pd.Timedelta(days=20)), + ) + if prices.empty or "Close" not in prices: + return pd.DataFrame() + + income = get_quarterly_income(ticker) + close = prices["Close"] + trading_days = close.index + + rows = [] + for edate, e in earnings.iterrows(): + # Entry day D = first trading day on/after the announcement. + after = trading_days[trading_days >= edate] + if len(after) == 0: + continue + d = after[0] + di = trading_days.get_loc(d) + if isinstance(di, slice): + di = di.start + + # Pre-earnings momentum (what the tape already priced in). + pre_5d = _ret(close, di, -5) + pre_20d = _ret(close, di, -20) + + # Forward post-earnings drift over each horizon (the 3-5 day "trend"). + fwd = {h: _ret(close, di, h) for h in HORIZONS} + if all(pd.isna(v) for v in fwd.values()): + continue + + # Profit fundamentals for the matching quarter (+ the year-ago quarter). + _, pos = _nearest_income_row(income, edate) + rev = ni = rev_yoy = ni_yoy = margin = margin_yoy = np.nan + if pos is not None: + cur = income.iloc[pos] + rev = _line(cur, "Total Revenue", "TotalRevenue", "Operating Revenue") + ni = _line(cur, "Net Income", "NetIncome", + "Net Income Common Stockholders") + margin = (ni / rev * 100.0) if rev not in (0, np.nan) else np.nan + if pos - 4 >= 0: # year-ago quarter for YoY + yago = income.iloc[pos - 4] + rev_y = _line(yago, "Total Revenue", "TotalRevenue", + "Operating Revenue") + ni_y = _line(yago, "Net Income", "NetIncome", + "Net Income Common Stockholders") + rev_yoy = _pct(rev, rev_y) + ni_yoy = _pct(ni, ni_y) + margin_y = (ni_y / rev_y * 100.0) if rev_y else np.nan + margin_yoy = (margin - margin_y) if pd.notna(margin_y) else np.nan + + rows.append( + { + "ticker": ticker, + "earnings_date": edate, + "entry_date": d, + "eps_estimate": e.get("eps_estimate", np.nan), + "eps_reported": e.get("eps_reported", np.nan), + "eps_surprise_pct": e.get("surprise_pct", np.nan), + "revenue_yoy": rev_yoy, + "net_income_yoy": ni_yoy, + "net_margin": margin, + "net_margin_yoy_chg": margin_yoy, + "pre_5d_return": pre_5d, + "pre_20d_return": pre_20d, + **{f"fwd_ret_{h}d": fwd[h] for h in HORIZONS}, + } + ) + + df = pd.DataFrame(rows) + for h in HORIZONS: + df[f"up_{h}d"] = (df[f"fwd_ret_{h}d"] > 0).astype(int) + return df + + +def _ret(close: pd.Series, i: int, offset: int): + """Return from bar i to bar i+offset (or i+offset..i if offset<0).""" + j = i + offset + if j < 0 or j >= len(close) or i < 0 or i >= len(close): + return np.nan + a, b = (close.iloc[j], close.iloc[i]) if offset < 0 else (close.iloc[i], close.iloc[j]) + if a == 0 or pd.isna(a) or pd.isna(b): + return np.nan + return float(b / a - 1.0) * 100.0 + + +def _pct(cur, prev): + if pd.isna(cur) or pd.isna(prev) or prev == 0: + return np.nan + return float((cur - prev) / abs(prev) * 100.0) + + +def build_dataset(tickers=DEFAULT_TICKERS) -> pd.DataFrame: + """Pooled, time-ordered event table across all tickers.""" + frames = [] + for t in tickers: + print(f" - {t}") + ev = build_events(t) + if not ev.empty: + frames.append(ev) + if not frames: + return pd.DataFrame() + out = pd.concat(frames, ignore_index=True) + return out.sort_values("earnings_date").reset_index(drop=True) + + +# --------------------------------------------------------------------------- # +# Modeling +# --------------------------------------------------------------------------- # +@dataclass +class ModelResult: + horizon: int + features: list = field(default_factory=list) + clf: object = None + reg: object = None + imputer: object = None + scaler: object = None + test_accuracy: float = float("nan") + baseline_accuracy: float = float("nan") + reg_r2: float = float("nan") + reg_mae: float = float("nan") + importances: pd.Series = None + report: str = "" + + +def train_models(df: pd.DataFrame, horizon: int, test_frac: float = 0.25): + """Fit direction classifier + magnitude regressor for one horizon. + + Split is chronological (train on the past, test on the most recent + events) so the score reflects real forward use, not leakage. + """ + from sklearn.ensemble import RandomForestClassifier + from sklearn.impute import SimpleImputer + from sklearn.linear_model import Ridge + from sklearn.metrics import accuracy_score, classification_report, mean_absolute_error, r2_score + from sklearn.preprocessing import StandardScaler + + ycol, ucol = f"fwd_ret_{horizon}d", f"up_{horizon}d" + data = df.dropna(subset=[ycol]).copy() + feats = [c for c in FEATURE_COLS if c in data.columns] + + n = len(data) + if n < 20: + return ModelResult(horizon=horizon, features=feats, + report=f"Only {n} events - too few to model reliably.") + + split = int(n * (1 - test_frac)) + train, test = data.iloc[:split], data.iloc[split:] + + # keep_empty_features keeps a column that is all-NaN in the train split + # (filled with 0) so the fitted feature vector always matches `feats`. + imp = SimpleImputer(strategy="median", keep_empty_features=True).fit(train[feats]) + sc = StandardScaler().fit(imp.transform(train[feats])) + Xtr = sc.transform(imp.transform(train[feats])) + Xte = sc.transform(imp.transform(test[feats])) + + # Direction classifier. + clf = RandomForestClassifier( + n_estimators=300, max_depth=4, min_samples_leaf=3, random_state=42 + ).fit(Xtr, train[ucol]) + pred = clf.predict(Xte) + acc = accuracy_score(test[ucol], pred) + # Baseline = always predict the majority class from training. + majority = int(train[ucol].mean() >= 0.5) + base = accuracy_score(test[ucol], np.full(len(test), majority)) + + # Magnitude regressor. + reg = Ridge(alpha=1.0).fit(Xtr, train[ycol]) + yhat = reg.predict(Xte) + r2 = r2_score(test[ycol], yhat) if len(test) > 1 else float("nan") + mae = mean_absolute_error(test[ycol], yhat) + + imp_series = ( + pd.Series(clf.feature_importances_, index=feats).sort_values(ascending=False) + ) + rep = classification_report(test[ucol], pred, zero_division=0) + + return ModelResult( + horizon=horizon, features=feats, clf=clf, reg=reg, imputer=imp, scaler=sc, + test_accuracy=acc, baseline_accuracy=base, reg_r2=r2, reg_mae=mae, + importances=imp_series, report=rep, + ) + + +def score_upcoming(ticker: str, model: ModelResult) -> dict: + """Score the latest available fundamentals for a ticker with a fitted model. + + Uses the most recent reported earnings event's features as a stand-in for + the next one. Returns predicted up-probability and expected N-day return. + """ + ev = build_events(ticker) + if ev.empty or model.clf is None: + return {} + row = ev.iloc[[-1]] + X = model.scaler.transform(model.imputer.transform(row[model.features])) + prob_up = float(model.clf.predict_proba(X)[0][1]) + exp_ret = float(model.reg.predict(X)[0]) + return { + "ticker": ticker, + "as_of_earnings": row["earnings_date"].iloc[0], + "horizon_days": model.horizon, + "prob_up": round(prob_up, 3), + "expected_return_pct": round(exp_ret, 2), + } diff --git a/earnings_trend_model/earnings_trend_model.ipynb b/earnings_trend_model/earnings_trend_model.ipynb new file mode 100644 index 0000000..b344cfe --- /dev/null +++ b/earnings_trend_model/earnings_trend_model.ipynb @@ -0,0 +1,305 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Earnings Profit → 3–5 Day Trend Model\n", + "\n", + "用**财报利润数据**预测个股在财报公布后 **3–5 个交易日**的价格走势(“财报漂移” post-earnings drift)。\n", + "\n", + "**思路**\n", + "\n", + "1. 对每只股票,拉取历史财报事件(EPS 预期 vs 实际)和季度利润表(营收、净利润)。\n", + "2. 为每个财报事件构造**利润特征**:EPS 惊喜度、营收同比、净利润同比、净利率及其变化、财报前动量。\n", + "3. 给每个事件打上**标签**:未来 3 / 5 交易日的前向收益与涨跌方向。\n", + "4. 在汇总的、按时间排序的事件表上训练分类器(方向)与回归器(幅度),并样本外评估。\n", + "\n", + "> 数据源用 `yfinance`,无需 API key,在 Colab / 本地都能直接跑。要换自己的股票,改下面的 `TICKERS` 即可。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 安装依赖" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q yfinance lxml pandas numpy scikit-learn matplotlib" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> **(可选)受限网络 / 代理环境**:若 yfinance 报 `Connection reset by peer`(企业/代理会重置 curl_cffi 的浏览器 TLS 指纹),运行下面这格把 yfinance 换成普通 requests 会话。Colab 上**无需**执行。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 仅在代理/受限网络下需要(Colab 可跳过)\n", + "# import os, requests\n", + "# os.environ[\"REQUESTS_CA_BUNDLE\"] = \"/path/to/ca-bundle.crt\" # 若有自签 CA\n", + "# _s = requests.Session(); _s.headers.update({\"User-Agent\": \"Mozilla/5.0\"})\n", + "# import earnings_trend as et; et.set_session(_s)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 配置(在这里改你的股票池)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 股票池 —— 换成你自己关心的代码即可\n", + "TICKERS = [\"AAPL\", \"MSFT\", \"NVDA\", \"GOOGL\", \"AMZN\",\n", + " \"META\", \"TSLA\", \"AMD\", \"NFLX\", \"JPM\"]\n", + "\n", + "# “3–5 天”的交易日地平线\n", + "HORIZONS = (3, 5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 导入模块\n", + "\n", + "核心逻辑在 `earnings_trend.py`。在 Colab 上请先把该文件上传,或 clone 仓库后把目录加入 path。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys, os\n", + "# 让 notebook 无论在哪个工作目录都能找到同目录的 earnings_trend.py\n", + "sys.path.insert(0, os.path.dirname(os.path.abspath(\"earnings_trend.py\")))\n", + "\n", + "import importlib\n", + "import earnings_trend as et\n", + "importlib.reload(et)\n", + "et.HORIZONS = HORIZONS\n", + "print(\"module loaded, features:\", et.FEATURE_COLS)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 拉取数据 + 构造事件表\n", + "\n", + "每一行 = 一个历史财报事件,包含利润特征与前向走势。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df = et.build_dataset(TICKERS)\n", + "print(f\"\\n共 {len(df)} 个财报事件,跨 {df['ticker'].nunique()} 只股票\")\n", + "df.tail(8)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 探索性分析:利润特征 vs 3–5 天走势" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "# 5a. 相关性热图:特征与前向收益\n", + "cols = et.FEATURE_COLS + [f\"fwd_ret_{h}d\" for h in HORIZONS]\n", + "corr = df[cols].corr()\n", + "fig, ax = plt.subplots(figsize=(9, 7))\n", + "im = ax.imshow(corr, cmap=\"RdBu_r\", vmin=-1, vmax=1)\n", + "ax.set_xticks(range(len(cols))); ax.set_xticklabels(cols, rotation=45, ha=\"right\")\n", + "ax.set_yticks(range(len(cols))); ax.set_yticklabels(cols)\n", + "for i in range(len(cols)):\n", + " for j in range(len(cols)):\n", + " ax.text(j, i, f\"{corr.iloc[i, j]:.2f}\", ha=\"center\", va=\"center\", fontsize=7)\n", + "fig.colorbar(im, fraction=0.046, pad=0.04)\n", + "ax.set_title(\"Correlation: profit features vs 3-5d forward return\")\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 5b. EPS 惊喜度 vs 前向收益(核心假设:超预期→上涨漂移)\n", + "fig, axes = plt.subplots(1, len(HORIZONS), figsize=(6 * len(HORIZONS), 4.5))\n", + "if len(HORIZONS) == 1: axes = [axes]\n", + "for ax, h in zip(axes, HORIZONS):\n", + " sub = df.dropna(subset=[\"eps_surprise_pct\", f\"fwd_ret_{h}d\"])\n", + " ax.scatter(sub[\"eps_surprise_pct\"], sub[f\"fwd_ret_{h}d\"], alpha=0.5, s=25)\n", + " if len(sub) > 2:\n", + " m, b = np.polyfit(sub[\"eps_surprise_pct\"], sub[f\"fwd_ret_{h}d\"], 1)\n", + " xs = np.linspace(sub[\"eps_surprise_pct\"].min(), sub[\"eps_surprise_pct\"].max(), 50)\n", + " ax.plot(xs, m * xs + b, \"r-\", lw=2, label=f\"slope={m:.3f}\")\n", + " ax.legend()\n", + " ax.axhline(0, color=\"gray\", lw=0.7); ax.axvline(0, color=\"gray\", lw=0.7)\n", + " ax.set_xlabel(\"EPS surprise (%)\"); ax.set_ylabel(f\"{h}-day forward return (%)\")\n", + " ax.set_title(f\"EPS surprise vs {h}d trend\")\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 5c. 按利润惊喜分组,看平均 3-5 天走势与胜率\n", + "def bucket(x):\n", + " if x <= -5: return \"1. 大幅不及 (<=-5%)\"\n", + " if x < 0: return \"2. 小幅不及 (-5~0%)\"\n", + " if x < 5: return \"3. 小幅超预期 (0~5%)\"\n", + " return \"4. 大幅超预期 (>=5%)\"\n", + "\n", + "g = df.dropna(subset=[\"eps_surprise_pct\"]).copy()\n", + "g[\"surprise_bucket\"] = g[\"eps_surprise_pct\"].apply(bucket)\n", + "agg = g.groupby(\"surprise_bucket\").agg(\n", + " n=(\"ticker\", \"size\"),\n", + " **{f\"avg_{h}d_%\": (f\"fwd_ret_{h}d\", \"mean\") for h in HORIZONS},\n", + " **{f\"winrate_{h}d\": (f\"up_{h}d\", \"mean\") for h in HORIZONS},\n", + ").round(3)\n", + "agg" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 训练模型(方向分类 + 幅度回归)\n", + "\n", + "按**时间顺序**划分训练/测试(用过去预测最近),避免未来数据泄露。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = {}\n", + "for h in HORIZONS:\n", + " r = et.train_models(df, horizon=h, test_frac=0.25)\n", + " results[h] = r\n", + " print(f\"===== {h}-day horizon =====\")\n", + " if r.clf is None:\n", + " print(r.report); print()\n", + " continue\n", + " print(f\"方向准确率 test={r.test_accuracy:.3f} vs 基线(多数类)={r.baseline_accuracy:.3f}\")\n", + " print(f\"幅度回归 R^2={r.reg_r2:.3f} MAE={r.reg_mae:.2f}%\")\n", + " print(\"特征重要性:\")\n", + " print(r.importances.round(3).to_string())\n", + " print()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 特征重要性可视化\n", + "valid = [h for h in HORIZONS if results[h].clf is not None]\n", + "if valid:\n", + " fig, axes = plt.subplots(1, len(valid), figsize=(6 * len(valid), 4))\n", + " if len(valid) == 1: axes = [axes]\n", + " for ax, h in zip(axes, valid):\n", + " imp = results[h].importances.sort_values()\n", + " ax.barh(imp.index, imp.values, color=\"steelblue\")\n", + " ax.set_title(f\"{h}-day: feature importance\")\n", + " plt.tight_layout(); plt.show()\n", + "else:\n", + " print(\"样本不足,无法画特征重要性——多加几只股票到 TICKERS 重试。\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 对当前股票打分(预测下一次/最近财报后的走势)\n", + "\n", + "用最新已公布财报的利润特征,给出上涨概率与预期收益。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "h = 5 if 5 in results and results[5].clf is not None else HORIZONS[0]\n", + "model = results[h]\n", + "scores = []\n", + "for t in TICKERS:\n", + " s = et.score_upcoming(t, model)\n", + " if s: scores.append(s)\n", + "import pandas as pd\n", + "board = pd.DataFrame(scores).sort_values(\"prob_up\", ascending=False).reset_index(drop=True)\n", + "print(f\"基于 {h}-天模型的打分(按上涨概率排序):\")\n", + "board" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 局限与下一步\n", + "\n", + "- **样本量**:每只股票只有十几个季度财报,想要统计显著性请把 `TICKERS` 扩到几十、上百只。\n", + "- **财报时点**:盘前/盘后公布会影响入场日。本模型统一取“公告日当天或之后的第一个交易日”为入场,衡量随后 3/5 日漂移。\n", + "- **更好的验证**:可换成 walk-forward / 滞后交叉验证,并加入交易成本象限。\n", + "- **数据源**:若已授权 Robinhood 连接器,可把 `get_earnings_dates` / `get_quarterly_income` 换成 Robinhood 的 `get_earnings_results` / `get_financials`(见 README)。\n", + "- 本 notebook 仅用于研究,**不构成投资建议**。" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file