DockerSpace / data /institutional_flow.py
DennisChan0909's picture
Backup current stock predictor strategies
ee37d63
Raw
History Blame Contribute Delete
14.4 kB
"""
Institutional investor buy/sell data for Taiwan stocks.
Sources:
- TWSE T86 daily report (listed stocks)
- TPEx institutional daily report (OTC/mainboard stocks)
The module is intentionally defensive: public endpoints can be late, absent on
holidays, or temporarily unavailable. Missing rows are represented as neutral
zero-flow records so the prediction pipeline keeps working.
"""
from __future__ import annotations
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from typing import Any
import pandas as pd
import requests
from cachetools import TTLCache
from data.fetcher import detect_exchange
logger = logging.getLogger(__name__)
TWSE_T86_URL = "https://www.twse.com.tw/rwd/zh/fund/T86"
TPEX_DAILY_URL = "https://www.tpex.org.tw/www/zh-tw/insti/dailyTrade"
FLOW_COLUMNS = [
"foreign_buy",
"foreign_sell",
"foreign_net",
"trust_buy",
"trust_sell",
"trust_net",
"dealer_buy",
"dealer_sell",
"dealer_net",
"institutional_buy",
"institutional_sell",
"institutional_net",
]
_DAILY_CACHE: TTLCache = TTLCache(maxsize=600, ttl=6 * 60 * 60)
_FLOW_CACHE: TTLCache = TTLCache(maxsize=100, ttl=30 * 60)
def _institutional_flow_enabled() -> bool:
value = os.getenv("ENABLE_INSTITUTIONAL_FLOW", "1").strip().lower()
return value not in {"0", "false", "no", "off"}
def _institutional_fetch_warnings_enabled() -> bool:
value = os.getenv("ENABLE_INSTITUTIONAL_FETCH_WARNINGS", "0").strip().lower()
return value in {"1", "true", "yes", "on"}
def _default_max_days() -> int:
configured = os.getenv("INSTITUTIONAL_FLOW_DAYS")
if configured is not None:
try:
return max(0, int(configured))
except ValueError:
logger.warning("Invalid INSTITUTIONAL_FLOW_DAYS=%r; falling back to default", configured)
# Hugging Face free CPU and LIGHTWEIGHT_MODE should avoid hundreds of
# external requests on cold start. 60 trading days still supports the 20d
# institutional z-score and 5d flow features.
if os.getenv("LIGHTWEIGHT_MODE", "0") == "1" or os.getenv("SPACE_ID"):
return 60
return 260
def _default_workers() -> int:
configured = os.getenv("INSTITUTIONAL_FLOW_WORKERS")
if configured is not None:
try:
return max(1, int(configured))
except ValueError:
logger.warning("Invalid INSTITUTIONAL_FLOW_WORKERS=%r; falling back to default", configured)
return 2 if (os.getenv("LIGHTWEIGHT_MODE", "0") == "1" or os.getenv("SPACE_ID")) else 6
def _parse_int(value: Any) -> int:
"""Parse TWSE/TPEx comma-formatted integer fields."""
if value is None:
return 0
text = str(value).strip().replace(",", "")
if text in ("", "--", "-"):
return 0
try:
return int(float(text))
except (TypeError, ValueError):
return 0
def _safe_get(row: list[Any], idx: int) -> int:
return _parse_int(row[idx]) if idx < len(row) else 0
def _neutral_row(stock_no: str, date_str: str, source: str = "") -> dict:
row = {
"date": date_str,
"stock_no": stock_no,
"institutional_available": False,
"institutional_source": source,
"institutional_as_of": None,
"institutional_carry_forward": False,
}
row.update({col: 0 for col in FLOW_COLUMNS})
return row
def _normalize_code(stock_no: str) -> str:
return stock_no.replace(".TW", "").replace(".TWO", "").strip()
def parse_twse_t86(payload: dict[str, Any], date_str: str) -> dict[str, dict]:
"""
Parse TWSE T86 JSON into a stock_no -> flow-row mapping.
TWSE columns:
2-4 foreign ex-dealer buy/sell/net
8-10 investment trust buy/sell/net
11 dealer total net
12-17 dealer self/hedge buy/sell/net
18 three-institution net
"""
if payload.get("stat") != "OK":
return {}
rows: dict[str, dict] = {}
for raw in payload.get("data", []) or []:
if len(raw) < 2:
continue
code = _normalize_code(str(raw[0]))
foreign_buy = _safe_get(raw, 2)
foreign_sell = _safe_get(raw, 3)
foreign_net = _safe_get(raw, 4)
trust_buy = _safe_get(raw, 8)
trust_sell = _safe_get(raw, 9)
trust_net = _safe_get(raw, 10)
dealer_net = _safe_get(raw, 11)
dealer_self_buy = _safe_get(raw, 12)
dealer_self_sell = _safe_get(raw, 13)
dealer_hedge_buy = _safe_get(raw, 15)
dealer_hedge_sell = _safe_get(raw, 16)
dealer_buy = dealer_self_buy + dealer_hedge_buy
dealer_sell = dealer_self_sell + dealer_hedge_sell
institutional_net = _safe_get(raw, 18)
institutional_buy = foreign_buy + trust_buy + dealer_buy
institutional_sell = foreign_sell + trust_sell + dealer_sell
rows[code] = {
"date": date_str,
"stock_no": code,
"foreign_buy": foreign_buy,
"foreign_sell": foreign_sell,
"foreign_net": foreign_net,
"trust_buy": trust_buy,
"trust_sell": trust_sell,
"trust_net": trust_net,
"dealer_buy": dealer_buy,
"dealer_sell": dealer_sell,
"dealer_net": dealer_net,
"institutional_buy": institutional_buy,
"institutional_sell": institutional_sell,
"institutional_net": institutional_net,
"institutional_available": True,
"institutional_source": "TWSE_T86",
"institutional_as_of": date_str,
"institutional_carry_forward": False,
}
return rows
def parse_tpex_daily(payload: dict[str, Any], date_str: str) -> dict[str, dict]:
"""
Parse TPEx institutional daily JSON into a stock_no -> flow-row mapping.
TPEx repeats generic buy/sell/net field names by investor group. The stable
positional layout is:
2-4 foreign ex-dealer, 11-13 investment trust, 20-22 dealer total,
23 three-institution net.
"""
tables = payload.get("tables") or []
if not tables:
return {}
data = tables[0].get("data", []) or []
rows: dict[str, dict] = {}
for raw in data:
if len(raw) < 2:
continue
code = _normalize_code(str(raw[0]))
foreign_buy = _safe_get(raw, 2)
foreign_sell = _safe_get(raw, 3)
foreign_net = _safe_get(raw, 4)
trust_buy = _safe_get(raw, 11)
trust_sell = _safe_get(raw, 12)
trust_net = _safe_get(raw, 13)
dealer_buy = _safe_get(raw, 20)
dealer_sell = _safe_get(raw, 21)
dealer_net = _safe_get(raw, 22)
institutional_net = _safe_get(raw, 23)
institutional_buy = foreign_buy + trust_buy + dealer_buy
institutional_sell = foreign_sell + trust_sell + dealer_sell
rows[code] = {
"date": date_str,
"stock_no": code,
"foreign_buy": foreign_buy,
"foreign_sell": foreign_sell,
"foreign_net": foreign_net,
"trust_buy": trust_buy,
"trust_sell": trust_sell,
"trust_net": trust_net,
"dealer_buy": dealer_buy,
"dealer_sell": dealer_sell,
"dealer_net": dealer_net,
"institutional_buy": institutional_buy,
"institutional_sell": institutional_sell,
"institutional_net": institutional_net,
"institutional_available": True,
"institutional_source": "TPEX_DAILY",
"institutional_as_of": date_str,
"institutional_carry_forward": False,
}
return rows
def _fetch_daily_market(source: str, date_str: str) -> dict[str, dict]:
"""Fetch and parse all institutional rows for one market/date."""
cache_key = f"{source}:{date_str}"
cached = _DAILY_CACHE.get(cache_key)
if cached is not None:
return cached
headers = {"User-Agent": "Mozilla/5.0"}
try:
if source == "TWSE":
resp = requests.get(
TWSE_T86_URL,
params={
"response": "json",
"date": date_str.replace("-", ""),
"selectType": "ALLBUT0999",
},
headers=headers,
timeout=8,
)
resp.raise_for_status()
rows = parse_twse_t86(resp.json(), date_str)
else:
resp = requests.get(
TPEX_DAILY_URL,
params={
"date": date_str.replace("-", "/"),
"type": "Daily",
"response": "json",
},
headers=headers,
timeout=8,
)
resp.raise_for_status()
rows = parse_tpex_daily(resp.json(), date_str)
except Exception as exc:
log = logger.warning if _institutional_fetch_warnings_enabled() else logger.debug
log("institutional %s fetch failed for %s: %s", source, date_str, exc)
rows = {}
_DAILY_CACHE[cache_key] = rows
return rows
def _row_for_date(stock_no: str, date_str: str, primary_exchange: str) -> dict:
"""Return one stock's institutional row for a date, trying both markets."""
bare = _normalize_code(stock_no)
sources = ["TPEX", "TWSE"] if primary_exchange == "TPEX" else ["TWSE", "TPEX"]
for source in sources:
daily = _fetch_daily_market(source, date_str)
if bare in daily:
return daily[bare]
return _neutral_row(bare, date_str)
def fetch_institutional_flow(
stock_no: str,
dates: list[str] | pd.Series | pd.Index,
*,
exchange: str | None = None,
max_days: int | None = None,
) -> pd.DataFrame:
"""
Fetch institutional flow rows aligned to the given price dates.
Only the most recent max_days are fetched from the public endpoints. Older
rows are neutral to keep initial training bounded and predictable.
"""
bare = _normalize_code(stock_no)
if max_days is None:
max_days = _default_max_days()
date_strings = [
pd.to_datetime(d).strftime("%Y-%m-%d")
for d in list(dates)
if pd.notna(d)
]
if not date_strings:
return pd.DataFrame()
if not _institutional_flow_enabled() or max_days <= 0:
return pd.DataFrame([_neutral_row(bare, d) for d in date_strings])
selected = date_strings[-max_days:]
cache_key = f"{bare}:{exchange or ''}:{','.join(selected)}"
cached = _FLOW_CACHE.get(cache_key)
if cached is not None:
return cached.copy()
primary_exchange = exchange or detect_exchange(bare)
older = date_strings[: max(0, len(date_strings) - len(selected))]
rows = [_neutral_row(bare, d) for d in older]
workers = min(8, _default_workers())
fetched_by_date: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers=workers) as executor:
future_map = {
executor.submit(_row_for_date, bare, d, primary_exchange): d
for d in selected
}
for future in as_completed(future_map):
d = future_map[future]
try:
fetched_by_date[d] = future.result()
except Exception as exc:
log = logger.warning if _institutional_fetch_warnings_enabled() else logger.debug
log("institutional row failed for %s %s: %s", bare, d, exc)
fetched_by_date[d] = _neutral_row(bare, d)
rows.extend(fetched_by_date.get(d, _neutral_row(bare, d)) for d in selected)
df = pd.DataFrame(rows).sort_values("date").reset_index(drop=True)
# If the latest trading day is not published yet, use the most recent
# already-published row for today's prediction without leaking future data.
if not df.empty and not bool(df.iloc[-1].get("institutional_available", False)):
prior = df[df["institutional_available"] == True] # noqa: E712
if not prior.empty:
prior_row = prior.iloc[-1]
last_idx = df.index[-1]
for col in FLOW_COLUMNS:
df.at[last_idx, col] = prior_row[col]
df.at[last_idx, "institutional_as_of"] = prior_row["institutional_as_of"]
df.at[last_idx, "institutional_source"] = prior_row["institutional_source"]
df.at[last_idx, "institutional_carry_forward"] = True
_FLOW_CACHE[cache_key] = df.copy()
return df
def add_institutional_flow(
df: pd.DataFrame,
stock_no: str,
*,
exchange: str | None = None,
max_days: int | None = None,
) -> pd.DataFrame:
"""Merge institutional flow columns into an OHLCV/indicator DataFrame."""
if df.empty or "date" not in df.columns:
return df
out = df.copy()
if not _institutional_flow_enabled():
for col in FLOW_COLUMNS:
out[col] = 0.0
out["institutional_available"] = False
out["institutional_source"] = ""
out["institutional_as_of"] = None
out["institutional_carry_forward"] = False
return out
flow = fetch_institutional_flow(
stock_no,
out["date"],
exchange=exchange,
max_days=max_days,
)
if flow.empty:
for col in FLOW_COLUMNS:
out[col] = 0
out["institutional_available"] = False
out["institutional_source"] = ""
out["institutional_as_of"] = None
out["institutional_carry_forward"] = False
return out
out["_flow_date"] = pd.to_datetime(out["date"]).dt.strftime("%Y-%m-%d")
merged = out.merge(
flow,
how="left",
left_on="_flow_date",
right_on="date",
suffixes=("", "_flow"),
)
merged = merged.drop(columns=[c for c in ["_flow_date", "date_flow", "stock_no_flow"] if c in merged.columns])
for col in FLOW_COLUMNS:
merged[col] = pd.to_numeric(merged.get(col, 0), errors="coerce").fillna(0).astype(float)
merged["institutional_available"] = merged.get("institutional_available", False).fillna(False).astype(bool)
merged["institutional_source"] = merged.get("institutional_source", "").fillna("")
merged["institutional_as_of"] = merged.get("institutional_as_of", None)
merged["institutional_carry_forward"] = merged.get("institutional_carry_forward", False).fillna(False).astype(bool)
return merged