""" Taiwan large holder percentage (大戶持股比例) from FinMind weekly shareholding data. Source: TaiwanStockHoldingSharesPer Tier: HoldingSharesLevel containing "1000" (1000+ lot holders = institutional/whale) """ from __future__ import annotations import logging from datetime import date, timedelta import numpy as np import pandas as pd import requests from cachetools import TTLCache logger = logging.getLogger(__name__) FINMIND_URL = "https://api.finmindtrade.com/api/v4/data" _LH_CACHE: TTLCache = TTLCache(maxsize=200, ttl=24 * 60 * 60) _HEADERS = {"User-Agent": "Mozilla/5.0"} _LH_COLS = ["large_holder_pct", "large_holder_4w_change"] def _normalize_code(stock_no: str) -> str: return stock_no.replace(".TW", "").replace(".TWO", "").strip() def fetch_large_holder(stock_no: str, months: int = 24) -> pd.DataFrame: bare = _normalize_code(stock_no) cache_key = f"lh:{bare}:{months}" cached = _LH_CACHE.get(cache_key) if cached is not None: return cached.copy() start = (date.today().replace(day=1) - timedelta(days=months * 31)).strftime("%Y-%m-%d") try: resp = requests.get( FINMIND_URL, params={"dataset": "TaiwanStockHoldingSharesPer", "data_id": bare, "start_date": start, "token": ""}, headers=_HEADERS, timeout=15, ) resp.raise_for_status() records = resp.json().get("data", []) except Exception as exc: logger.warning("FinMind large holder fetch failed for %s: %s", bare, exc) return pd.DataFrame() if not records: return pd.DataFrame() try: df = pd.DataFrame(records) # Filter to 1000+ lot tier (level label varies: "1000張以上", ">1000", etc.) mask = df["HoldingSharesLevel"].astype(str).str.contains("1000", na=False) df = df[mask][["date", "percent"]].copy() df["percent"] = pd.to_numeric(df["percent"], errors="coerce").fillna(0.0) df = df.sort_values("date").reset_index(drop=True) df.columns = ["date", "large_holder_pct"] _LH_CACHE[cache_key] = df.copy() return df except Exception as exc: logger.warning("Large holder parse failed for %s: %s", bare, exc) return pd.DataFrame() def add_large_holder_features(df: pd.DataFrame, stock_no: str) -> pd.DataFrame: out = df.copy() def _zero_fill(): for col in _LH_COLS: out[col] = 0.0 return out if out.empty or "date" not in out.columns: return _zero_fill() lh = fetch_large_holder(stock_no) if lh.empty: return _zero_fill() # Use merge_asof to forward-fill weekly data into daily rows (no lookahead) daily_dates = pd.to_datetime(out["date"]).dt.normalize() lh_dates = pd.to_datetime(lh["date"]).dt.normalize() lh_sorted = lh.copy() lh_sorted["date_dt"] = lh_dates daily_df = pd.DataFrame({"date_dt": daily_dates}) merged = pd.merge_asof( daily_df.sort_values("date_dt"), lh_sorted.sort_values("date_dt"), on="date_dt", direction="backward", ) merged = merged.set_index(daily_df.sort_values("date_dt").index) pct = merged["large_holder_pct"].fillna(0.0) # 4-week (28-day) change in large holder %: ~4 weekly observations back pct_4w = pct.shift(4).replace(0, np.nan) change_4w = (pct - pct_4w).fillna(0.0) # Re-align to original df index order orig_order = daily_dates.argsort().argsort() out["large_holder_pct"] = pct.iloc[daily_dates.argsort()].values[orig_order] if len(pct) == len(out) else 0.0 out["large_holder_4w_change"] = change_4w.iloc[daily_dates.argsort()].values[orig_order] if len(change_4w) == len(out) else 0.0 return out