Spaces:
Running
Running
feat(C2): add PTT sentiment scraper and backtest; FAILED — PTT rows=0, features not added to model
cec37e3 | """ | |
| PTT Stock board sentiment scraper. | |
| Produces daily sentiment score per stock from [標的] posts. | |
| Score = sum of (推 - 噓) for posts mentioning a stock on that date. | |
| """ | |
| import json | |
| import re | |
| import time | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| import pandas as pd | |
| import requests | |
| CACHE_DIR = Path.home() / ".cache" / "stock_predictor" | |
| CACHE_FILE = CACHE_DIR / "ptt_sentiment.json" | |
| CACHE_TTL_HOURS = 6 | |
| PTT_BASE = "https://www.ptt.cc" | |
| SESSION_COOKIES = {"over18": "1"} | |
| HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; stock-predictor/1.0)"} | |
| # Regex for TWSE 4-digit stock codes (avoid matching years like 2026) | |
| STOCK_RE = re.compile(r'\b([2-9]\d{3}|[0-1]\d{3})\b') | |
| KNOWN_STOCKS = { # common codes to disambiguate | |
| "2330", "2317", "2454", "2881", "2882", "2886", "2891", | |
| "0050", "0056", "2303", "2308", "2357", "2382", "2412", | |
| "3008", "3034", "3045", "4904", "4938", "6505", "6669", | |
| } | |
| def _parse_nrec(nrec_text: str) -> int: | |
| """Parse PTT nrec field: '爆'→100, 'X{n}'→negative, digits→int.""" | |
| t = nrec_text.strip() | |
| if t == "爆": | |
| return 100 | |
| if t.startswith("X"): | |
| try: | |
| return -int(t[1:]) | |
| except ValueError: | |
| return -10 | |
| try: | |
| return int(t) | |
| except ValueError: | |
| return 0 | |
| def _extract_stock_codes(title: str) -> list[str]: | |
| """Extract TWSE-style 4-digit codes from post title.""" | |
| candidates = STOCK_RE.findall(title) | |
| # Prefer known codes; if none found, return all 4-digit candidates | |
| known = [c for c in candidates if c in KNOWN_STOCKS] | |
| return known if known else candidates[:3] # cap at 3 to avoid noise | |
| def _fetch_page(url: str, session: requests.Session) -> tuple[list[dict], str | None]: | |
| """ | |
| Fetch one PTT index page. Returns (posts_list, prev_page_url). | |
| Each post dict: {title, author, date_str, href, nrec, stocks}. | |
| """ | |
| try: | |
| resp = session.get(url, headers=HEADERS, timeout=10) | |
| resp.raise_for_status() | |
| except Exception: | |
| return [], None | |
| from html.parser import HTMLParser | |
| class _Parser(HTMLParser): | |
| def __init__(self): | |
| super().__init__() | |
| self.posts = [] | |
| self.prev_url = None | |
| self._in_title = self._in_author = self._in_date = self._in_nrec = False | |
| self._cur = {} | |
| def handle_starttag(self, tag, attrs): | |
| a = dict(attrs) | |
| cls = a.get("class", "") | |
| if tag == "div" and "r-ent" in cls: | |
| self._cur = {} | |
| if tag == "div" and cls == "title": | |
| self._in_title = True | |
| if tag == "div" and cls == "author": | |
| self._in_author = True | |
| if tag == "div" and cls == "date": | |
| self._in_date = True | |
| if tag == "div" and cls == "nrec": | |
| self._in_nrec = True | |
| if tag == "a" and self._in_title and "href" in a: | |
| self._cur["href"] = a["href"] | |
| # Prev page button | |
| if tag == "a" and a.get("class") == "btn wide" and "上頁" in (a.get("title") or ""): | |
| self.prev_url = a.get("href") | |
| # Find btn wide links with text containing 上頁 via data | |
| if tag == "a" and "btn wide" in cls: | |
| self._last_btn_href = a.get("href") | |
| def handle_data(self, data): | |
| if self._in_title: | |
| self._cur["title"] = self._cur.get("title", "") + data | |
| if self._in_author: | |
| self._cur["author"] = data.strip() | |
| self._in_author = False | |
| if self._in_date: | |
| self._cur["date_str"] = data.strip() | |
| self._in_date = False | |
| if self._in_nrec: | |
| self._cur["nrec_text"] = data.strip() | |
| self._in_nrec = False | |
| if data.strip() == "上頁" and hasattr(self, "_last_btn_href"): | |
| self.prev_url = self._last_btn_href | |
| def handle_endtag(self, tag): | |
| if tag == "div" and self._in_title: | |
| self._in_title = False | |
| if self._cur.get("href") and self._cur.get("title"): | |
| self.posts.append(dict(self._cur)) | |
| parser = _Parser() | |
| parser.feed(resp.text) | |
| # Fallback: find prev page via regex | |
| if not parser.prev_url: | |
| m = re.search(r'href="(/bbs/Stock/index\d+\.html)"[^>]*>.*?上頁', resp.text, re.S) | |
| if m: | |
| parser.prev_url = m.group(1) | |
| posts = [] | |
| for p in parser.posts: | |
| title = p.get("title", "").strip() | |
| if "[標的]" not in title: | |
| continue | |
| nrec = _parse_nrec(p.get("nrec_text", "0")) | |
| stocks = _extract_stock_codes(title) | |
| date_str = p.get("date_str", "").strip() | |
| posts.append({ | |
| "title": title, | |
| "href": p.get("href", ""), | |
| "nrec": nrec, | |
| "stocks": stocks, | |
| "date_str": date_str, | |
| }) | |
| return posts, parser.prev_url | |
| def _date_from_str(date_str: str, year: int) -> datetime | None: | |
| """Parse PTT date like ' 5/11' → datetime(year, 5, 11).""" | |
| try: | |
| clean = date_str.strip() | |
| # PTT format: ' 5/11' or '5/11' | |
| parts = clean.split("/") | |
| if len(parts) == 2: | |
| month, day = int(parts[0].strip()), int(parts[1].strip()) | |
| return datetime(year, month, day) | |
| except Exception: | |
| pass | |
| return None | |
| def scrape_ptt_sentiment(days_back: int = 180) -> pd.DataFrame: | |
| """ | |
| Scrape PTT [標的] posts going back `days_back` days. | |
| Returns DataFrame with columns: date (date), stock_no (str), sentiment_score (int). | |
| """ | |
| session = requests.Session() | |
| session.cookies.update(SESSION_COOKIES) | |
| cutoff = datetime.now() - timedelta(days=days_back) | |
| current_year = datetime.now().year | |
| all_posts = [] | |
| url = f"{PTT_BASE}/bbs/Stock/index.html" | |
| pages_fetched = 0 | |
| max_pages = 300 # safety cap | |
| while url and pages_fetched < max_pages: | |
| posts, prev_url = _fetch_page(f"{PTT_BASE}{url}" if url.startswith("/") else url, session) | |
| pages_fetched += 1 | |
| stop = False | |
| for p in posts: | |
| dt = _date_from_str(p["date_str"], current_year) | |
| if dt is None: | |
| # Try previous year (December posts when we're in January) | |
| dt = _date_from_str(p["date_str"], current_year - 1) | |
| if dt is None: | |
| continue | |
| if dt < cutoff: | |
| stop = True | |
| break | |
| for stock in p["stocks"]: | |
| all_posts.append({ | |
| "date": dt.date(), | |
| "stock_no": stock, | |
| "sentiment_score": p["nrec"], | |
| }) | |
| time.sleep(0.3) | |
| if stop or not prev_url: | |
| break | |
| url = prev_url | |
| if not all_posts: | |
| return pd.DataFrame(columns=["date", "stock_no", "sentiment_score"]) | |
| df = pd.DataFrame(all_posts) | |
| df["date"] = pd.to_datetime(df["date"]) | |
| # Aggregate: sum scores per (date, stock_no) | |
| agg = df.groupby(["date", "stock_no"])["sentiment_score"].sum().reset_index() | |
| return agg | |
| def load_ptt_sentiment(days_back: int = 180, force_refresh: bool = False) -> pd.DataFrame: | |
| """Load from cache or scrape fresh.""" | |
| CACHE_DIR.mkdir(parents=True, exist_ok=True) | |
| if not force_refresh and CACHE_FILE.exists(): | |
| age_hours = (time.time() - CACHE_FILE.stat().st_mtime) / 3600 | |
| if age_hours < CACHE_TTL_HOURS: | |
| try: | |
| data = json.loads(CACHE_FILE.read_text()) | |
| df = pd.DataFrame(data) | |
| df["date"] = pd.to_datetime(df["date"]) | |
| return df | |
| except Exception: | |
| pass | |
| df = scrape_ptt_sentiment(days_back=days_back) | |
| if not df.empty: | |
| CACHE_FILE.write_text(json.dumps(df.assign(date=df["date"].dt.strftime("%Y-%m-%d")).to_dict("records"))) | |
| return df | |
| def add_ptt_sentiment(df: pd.DataFrame, stock_no: str) -> pd.DataFrame: | |
| """ | |
| Add ptt_sentiment_1d and ptt_sentiment_5d_ma to df. | |
| Uses merge_asof(direction="backward") to avoid lookahead. | |
| Missing dates → 0.0. | |
| """ | |
| ptt = load_ptt_sentiment() | |
| if ptt.empty: | |
| df["ptt_sentiment_1d"] = 0.0 | |
| df["ptt_sentiment_5d_ma"] = 0.0 | |
| return df | |
| stock_ptt = ptt[ptt["stock_no"] == str(stock_no)].copy() | |
| if stock_ptt.empty: | |
| df["ptt_sentiment_1d"] = 0.0 | |
| df["ptt_sentiment_5d_ma"] = 0.0 | |
| return df | |
| stock_ptt = stock_ptt.sort_values("date").rename( | |
| columns={"sentiment_score": "ptt_sentiment_1d"} | |
| ) | |
| # 5-day rolling mean on PTT side | |
| stock_ptt["ptt_sentiment_5d_ma"] = ( | |
| stock_ptt["ptt_sentiment_1d"].rolling(5, min_periods=1).mean() | |
| ) | |
| # Align to df by date — use merge_asof so we never look ahead | |
| if "date" in df.columns: | |
| df_dates = pd.to_datetime(df["date"]) | |
| else: | |
| df_dates = pd.to_datetime(df.index) | |
| df_tmp = pd.DataFrame({"date": df_dates}) | |
| merged = pd.merge_asof( | |
| df_tmp.sort_values("date"), | |
| stock_ptt[["date", "ptt_sentiment_1d", "ptt_sentiment_5d_ma"]].sort_values("date"), | |
| on="date", direction="backward", | |
| ) | |
| # Reindex back to original df order | |
| df = df.copy() | |
| df["ptt_sentiment_1d"] = merged["ptt_sentiment_1d"].fillna(0.0).values | |
| df["ptt_sentiment_5d_ma"] = merged["ptt_sentiment_5d_ma"].fillna(0.0).values | |
| return df | |