zhulinsen--daily_stock_analysis
399 行
17 KiB
Python
399 行
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""TwInstitutionalFetcher — Taiwan 三大法人 (institutional-investor) daily net buy/sell.
|
|
|
|
Data-layer only, ``tw``-only, strictly additive. This module is a self-contained
|
|
data-access building block: it fetches, parses, caches and fail-opens. It is NOT
|
|
wired into the analysis report / Web / scoring path — that is a deliberate
|
|
follow-up (per #1777). It does not touch the existing A-share / HK / US / JP / KR
|
|
flows in ``data_provider/base.py``.
|
|
|
|
Sources (政府開放資料, 政府資料開放授權條款第 1 版 / OGDL v1, commercial-safe, no key):
|
|
- 上市 TWSE T86 「三大法人買賣超日報」 (per-stock), legacy RWD JSON endpoint
|
|
https://www.twse.com.tw/rwd/zh/fund/T86?response=json&date=YYYYMMDD&selectType=ALLBUT0999
|
|
(date is 西元 ``YYYYMMDD``; numeric values are comma-formatted strings)
|
|
- 上櫃 TPEx ``tpex_3insti_daily_trading``, OpenAPI
|
|
https://www.tpex.org.tw/openapi/v1/tpex_3insti_daily_trading
|
|
(date is 民國 ``1150626``; numeric values are plain integer strings)
|
|
|
|
Fail-open contract: any network error, rate-limit, empty response, unexpected
|
|
shape or missing field returns ``None`` (no data) — it never raises into the
|
|
caller, so the analysis main flow is never interrupted.
|
|
|
|
Units are **shares (股)**, not lots (張). Buy/sell-net signs are preserved
|
|
(negative = net sell).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
import requests
|
|
|
|
from data_provider.realtime_types import CircuitBreaker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_T86_URL = "https://www.twse.com.tw/rwd/zh/fund/T86"
|
|
_TPEX_URL = "https://www.tpex.org.tw/openapi/v1/tpex_3insti_daily_trading"
|
|
_UA = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
|
)
|
|
|
|
# TWSE T86 core column NAMES. Read by name (not a fixed index) so a TWSE column
|
|
# rename / reorder fails open instead of silently shipping misaligned numbers.
|
|
# foreign = 外陸資 (NOT incl 外資自營商): foreign-dealer sits outside the 外資
|
|
# category in the official 三大法人 total.
|
|
_T86_CODE = "證券代號"
|
|
_T86_FOREIGN = "外陸資買賣超股數(不含外資自營商)"
|
|
_T86_TRUST = "投信買賣超股數"
|
|
_T86_DEALER = "自營商買賣超股數"
|
|
_T86_TOTAL = "三大法人買賣超股數"
|
|
_T86_CORE = (_T86_CODE, _T86_FOREIGN, _T86_TRUST, _T86_DEALER, _T86_TOTAL)
|
|
|
|
# TPEx OpenAPI column keys (verified live 2026-06; note the inconsistent spacing in
|
|
# the official feed). foreign = dealer-excluded, matching TotalDifference =
|
|
# foreign-excl + trust + dealer.
|
|
_TPEX_FOREIGN_EXCL = (
|
|
"Foreign Investors include Mainland Area Investors "
|
|
"(Foreign Dealers excluded)-Difference"
|
|
)
|
|
_TPEX_TRUST = "SecuritiesInvestmentTrustCompanies-Difference"
|
|
_TPEX_DEALER = "Dealers-Difference"
|
|
_TPEX_TOTAL = "TotalDifference"
|
|
|
|
|
|
def _to_int(value: Any) -> Optional[int]:
|
|
"""Parse a TWSE/TPEx numeric cell to int, preserving sign.
|
|
|
|
Handles comma grouping (T86) and plain ints (TPEx). Empty / ``--`` / ``-`` /
|
|
non-numeric -> ``None`` (treated as missing, never a fabricated 0).
|
|
"""
|
|
try:
|
|
text = str(value).replace(",", "").replace(" ", "").strip()
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if text in ("", "-", "--", "—"):
|
|
return None
|
|
try:
|
|
return int(text)
|
|
except ValueError:
|
|
try:
|
|
return int(float(text))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def minguo_to_ad(date_str: Any) -> Optional[str]:
|
|
"""Convert a TPEx 民國 date ``YYYMMDD`` (e.g. ``1150626``) to 西元 ``YYYYMMDD``.
|
|
|
|
``1150626`` -> ``20260626`` (民國 115 + 1911 = 西元 2026). Returns ``None`` for
|
|
anything that is not a 7-digit 民國 date, so a format change fails open.
|
|
"""
|
|
text = str(date_str).strip()
|
|
if not (text.isdigit() and len(text) == 7):
|
|
return None
|
|
return f"{int(text[:3]) + 1911}{text[3:]}"
|
|
|
|
|
|
class TwInstitutionalFetcher:
|
|
"""Fetch Taiwan per-stock 三大法人 net buy/sell, ``.TW`` (上市) / ``.TWO`` (上櫃) only."""
|
|
|
|
name = "TwInstitutionalFetcher"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cache_ttl_seconds: int = 900,
|
|
min_request_interval: float = 1.8,
|
|
timeout: int = 15,
|
|
) -> None:
|
|
# Whole-market single-day cache keyed by (market, ad_date); filtered per stock.
|
|
self._cache: Dict[Any, Dict[str, dict]] = {}
|
|
self._cache_at: Dict[Any, float] = {}
|
|
self._cache_ttl = cache_ttl_seconds
|
|
self._timeout = timeout
|
|
# TWSE T86 RWD endpoint has an informal ~3 req / 5 s ban; throttle requests.
|
|
self._min_interval = min_request_interval
|
|
self._last_request_at = 0.0
|
|
self._lock = threading.Lock()
|
|
self._throttle_lock = threading.Lock()
|
|
# One lock per unique (market, ad_date) key; bounded by tw markets x
|
|
# distinct dates queried -- low thousands at most, negligible memory.
|
|
self._inflight: Dict[Any, threading.Lock] = {}
|
|
# Per-market circuit breaker (keyed "twse"/"tpex"): when an endpoint is down
|
|
# (>= 3 consecutive failures) skip the network round-trip for ~5 min and fail
|
|
# open, instead of paying timeout + throttle on every stock during an outage.
|
|
# Reuses the repo's CircuitBreaker (same one DataFetcherManager uses).
|
|
self._breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=300.0)
|
|
|
|
# ------------------------------------------------------------------ public
|
|
def get_institutional_net(
|
|
self, stock_code: str, date: Optional[str] = None
|
|
) -> Optional[dict]:
|
|
"""Return the normalized 三大法人 record for one TW stock, or ``None``.
|
|
|
|
``stock_code`` must carry an explicit ``.TW`` / ``.TWO`` suffix; a bare or
|
|
non-TW code returns ``None`` (not applicable). ``date`` (西元 ``YYYYMMDD``)
|
|
only applies to 上市/T86; 上櫃/TPEx OpenAPI serves the latest trading day.
|
|
Fail-open: any error returns ``None``.
|
|
"""
|
|
market = self._market_of(stock_code)
|
|
if market is None:
|
|
return None
|
|
base = self._base_code(stock_code)
|
|
try:
|
|
table = self._whole_market(market, date)
|
|
except Exception as exc: # noqa: BLE001 - fail-open by contract
|
|
logger.info(
|
|
"[tw-inst] fetch failed market=%s code=%s: %s", market, stock_code, exc
|
|
)
|
|
return None
|
|
if not table:
|
|
return None
|
|
record = table.get(base)
|
|
# TPEx OpenAPI serves only the LATEST trading day (no date param). If a caller
|
|
# asked for a specific date, never silently return a different-day record --
|
|
# fail open (None) so a date-mismatched 上櫃 figure can't reach a report.
|
|
if record is not None and date and market == "tpex":
|
|
requested = self._norm_ad_date(date)
|
|
if requested and record.get("date") != requested:
|
|
logger.info(
|
|
"[tw-inst] TPEx %s requested date %s != served %s -> fail-open",
|
|
base, requested, record.get("date"),
|
|
)
|
|
return None
|
|
return record
|
|
|
|
# ------------------------------------------------------------------ routing
|
|
@staticmethod
|
|
def _market_of(stock_code: Any) -> Optional[str]:
|
|
upper = str(stock_code or "").strip().upper()
|
|
if upper.endswith(".TWO"):
|
|
return "tpex"
|
|
if upper.endswith(".TW"):
|
|
return "twse"
|
|
return None
|
|
|
|
@staticmethod
|
|
def _base_code(stock_code: Any) -> str:
|
|
return str(stock_code or "").strip().upper().rsplit(".", 1)[0]
|
|
|
|
@staticmethod
|
|
def _norm_ad_date(date: Any) -> Optional[str]:
|
|
if not date:
|
|
return None
|
|
text = str(date).strip().replace("-", "").replace("/", "")
|
|
return text if (text.isdigit() and len(text) == 8) else None
|
|
|
|
# -------------------------------------------------- whole-market cached fetch
|
|
def _whole_market(self, market: str, date: Optional[str]) -> Dict[str, dict]:
|
|
"""Whole-market single-day table {code: record}, cached per (market, date).
|
|
|
|
May raise on network / HTTP errors -- the public get_institutional_net wraps
|
|
this in a fail-open try/except. Only non-empty results are cached, so a
|
|
transient rate-limit / empty response is retried on the next call rather
|
|
than serving an empty table for the whole TTL.
|
|
|
|
Concurrent callers for the SAME (market, date) coalesce into a single
|
|
upstream fetch (cache-stampede guard) -- this keeps the T86 ~3 req/5 s
|
|
budget intact under parallel callers; different keys still fetch in
|
|
parallel, and the master lock is never held across network I/O. On a fetch
|
|
error the key-lock is released and waiting callers each retry independently
|
|
(serialized only by _throttle), since failures are deliberately not cached.
|
|
"""
|
|
ad_date = self._norm_ad_date(date) if market == "twse" else None
|
|
key = (market, ad_date)
|
|
cached = self._read_cache(key)
|
|
if cached is not None:
|
|
return cached
|
|
# Serialize same-key fetches so a burst of callers issues ONE request, not N.
|
|
with self._key_lock(key):
|
|
cached = self._read_cache(key) # double-check: a prior holder may have filled it
|
|
if cached is not None:
|
|
return cached
|
|
# Circuit breaker: if this endpoint has been failing (>= 3 in a row), skip
|
|
# the network round-trip and fail open (empty) until the ~5 min cooldown
|
|
# half-opens -- so a TWSE/TPEx outage costs ~0 per stock, not timeout+throttle.
|
|
if not self._breaker.is_available(market):
|
|
logger.info("[tw-inst] %s circuit OPEN -> skip fetch, fail-open", market)
|
|
return {}
|
|
try:
|
|
table = self._fetch_twse(ad_date) if market == "twse" else self._fetch_tpex()
|
|
except Exception as exc: # network / HTTP error -> trip the breaker, then re-raise
|
|
self._breaker.record_failure(market, str(exc))
|
|
raise
|
|
# The breaker tracks REACHABILITY (open only on hard network/HTTP errors).
|
|
# An empty / stat!=OK body still means the endpoint RESPONDED, so it counts
|
|
# as success: it resets the failure streak and, during HALF_OPEN recovery,
|
|
# closes the breaker instead of re-opening it (so a no-data day mid-recovery
|
|
# can never strand the breaker open). Only non-empty tables are cached.
|
|
self._breaker.record_success(market)
|
|
if table: # never cache an empty / failed fetch -> no TTL-long blackout
|
|
with self._lock:
|
|
self._cache[key] = table
|
|
self._cache_at[key] = time.time()
|
|
return table
|
|
|
|
def _read_cache(self, key: Any) -> Optional[Dict[str, dict]]:
|
|
with self._lock:
|
|
cached = self._cache.get(key)
|
|
if cached is not None and (time.time() - self._cache_at.get(key, 0.0)) < self._cache_ttl:
|
|
return cached
|
|
return None
|
|
|
|
def _key_lock(self, key: Any) -> threading.Lock:
|
|
with self._lock:
|
|
lock = self._inflight.get(key)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
self._inflight[key] = lock
|
|
return lock
|
|
|
|
def _throttle(self) -> None:
|
|
with self._throttle_lock:
|
|
wait = self._min_interval - (time.time() - self._last_request_at)
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
self._last_request_at = time.time()
|
|
|
|
def _get_json(self, url: str, params: Optional[dict] = None) -> Any:
|
|
self._throttle()
|
|
resp = requests.get(
|
|
url,
|
|
params=params,
|
|
headers={"User-Agent": _UA, "Accept": "application/json"},
|
|
timeout=self._timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
# ------------------------------------------------------------- TWSE T86 (上市)
|
|
def _fetch_twse(self, ad_date: Optional[str]) -> Dict[str, dict]:
|
|
params = {"response": "json", "selectType": "ALLBUT0999"}
|
|
if ad_date:
|
|
params["date"] = ad_date
|
|
payload = self._get_json(_T86_URL, params)
|
|
if not isinstance(payload, dict) or payload.get("stat") != "OK":
|
|
return {}
|
|
rows = payload.get("data")
|
|
if not isinstance(rows, list) or not rows:
|
|
return {}
|
|
idx = self._t86_index_map(payload.get("fields"))
|
|
if idx is None: # header missing or a core column renamed/removed -> fail-open
|
|
logger.info("[tw-inst] T86 fields header missing/renamed -> fail-open")
|
|
return {}
|
|
payload_date = self._norm_ad_date(payload.get("date")) or ad_date
|
|
table: Dict[str, dict] = {}
|
|
for row in rows:
|
|
record = self._parse_t86_row(row, payload_date, idx)
|
|
if record is not None:
|
|
table[record["stock_code"]] = record
|
|
return table
|
|
|
|
@staticmethod
|
|
def _t86_index_map(fields: Any) -> Optional[Dict[str, int]]:
|
|
"""Map each core T86 column NAME to its index, or None if any is missing.
|
|
|
|
Reading by name (not a fixed index) means a TWSE column rename / reorder
|
|
fails open rather than silently shipping misaligned foreign/trust/dealer
|
|
numbers under stale indices.
|
|
"""
|
|
if not isinstance(fields, list):
|
|
return None
|
|
idx: Dict[str, int] = {}
|
|
for name in _T86_CORE:
|
|
try:
|
|
idx[name] = fields.index(name)
|
|
except ValueError:
|
|
return None
|
|
return idx
|
|
|
|
@staticmethod
|
|
def _parse_t86_row(
|
|
row: Any, ad_date: Optional[str], idx: Dict[str, int]
|
|
) -> Optional[dict]:
|
|
if not isinstance(row, (list, tuple)) or any(i >= len(row) for i in idx.values()):
|
|
return None
|
|
if ad_date is None: # data with no attributable trading date -> fail-open
|
|
return None
|
|
code = str(row[idx[_T86_CODE]]).strip()
|
|
if not code:
|
|
return None
|
|
foreign = _to_int(row[idx[_T86_FOREIGN]]) # 外陸資 (ex 外資自營商)
|
|
trust = _to_int(row[idx[_T86_TRUST]])
|
|
dealer = _to_int(row[idx[_T86_DEALER]])
|
|
total = _to_int(row[idx[_T86_TOTAL]])
|
|
# A None core component means a missing / unparseable column (NOT genuine 0,
|
|
# which parses to 0) -> drop the row so a report never reads a fabricated zero.
|
|
if foreign is None or trust is None or dealer is None:
|
|
return None
|
|
return TwInstitutionalFetcher._build_record(
|
|
code, ad_date, "上市", "TWSE-T86", foreign, trust, dealer, total
|
|
)
|
|
|
|
# -------------------------------------------------------------- TPEx (上櫃)
|
|
def _fetch_tpex(self) -> Dict[str, dict]:
|
|
payload = self._get_json(_TPEX_URL)
|
|
if not isinstance(payload, list) or not payload:
|
|
return {}
|
|
table: Dict[str, dict] = {}
|
|
for raw in payload:
|
|
record = self._parse_tpex_row(raw)
|
|
if record is not None:
|
|
table[record["stock_code"]] = record
|
|
return table
|
|
|
|
@staticmethod
|
|
def _parse_tpex_row(raw: Any) -> Optional[dict]:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
code = str(raw.get("SecuritiesCompanyCode", "")).strip()
|
|
if not code:
|
|
return None
|
|
ad_date = minguo_to_ad(raw.get("Date", ""))
|
|
if ad_date is None: # 民國 date unconvertible -> no attributable day -> fail-open
|
|
return None
|
|
foreign = _to_int(raw.get(_TPEX_FOREIGN_EXCL)) # dealer-excluded foreign
|
|
trust = _to_int(raw.get(_TPEX_TRUST))
|
|
dealer = _to_int(raw.get(_TPEX_DEALER))
|
|
total = _to_int(raw.get(_TPEX_TOTAL))
|
|
# A None core component means a missing / renamed column -> fail-open (never a
|
|
# fabricated 0). Genuine zero activity parses to 0 and is kept.
|
|
if foreign is None or trust is None or dealer is None:
|
|
return None
|
|
return TwInstitutionalFetcher._build_record(
|
|
code, ad_date, "上櫃", "TPEx-OpenAPI", foreign, trust, dealer, total
|
|
)
|
|
|
|
# -------------------------------------------------------------- normalize
|
|
@staticmethod
|
|
def _build_record(
|
|
code: str,
|
|
ad_date: Optional[str],
|
|
market_label: str,
|
|
source: str,
|
|
foreign: int,
|
|
trust: int,
|
|
dealer: int,
|
|
total: Optional[int],
|
|
) -> dict:
|
|
# foreign / trust / dealer are guaranteed non-None by the parsers (a missing
|
|
# component fails the row open upstream), so a genuine 0 is preserved as 0
|
|
# and is never confused with a missing column.
|
|
return {
|
|
"stock_code": code,
|
|
"date": ad_date,
|
|
"market": market_label,
|
|
"source": source,
|
|
"unit": "shares",
|
|
"foreign_net": foreign,
|
|
"trust_net": trust,
|
|
"dealer_net": dealer,
|
|
# Official total when present; otherwise the component sum (kept consistent).
|
|
"total_net": total if total is not None else foreign + trust + dealer,
|
|
}
|