Spaces:
Running
Running
| """Durable SQLite cache for Taiwan institutional investor flow rows.""" | |
| from __future__ import annotations | |
| import sqlite3 | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| from data.institutional_flow import FLOW_COLUMNS | |
| ROOT = Path(__file__).resolve().parent.parent | |
| DEFAULT_CACHE_PATH = ROOT / "data" / "cache" / "institutional_flow.sqlite" | |
| DAILY_TABLE = "institutional_daily" | |
| STATUS_TABLE = "institutional_fetch_status" | |
| DAILY_COLUMNS = [ | |
| "date", | |
| "stock_no", | |
| *FLOW_COLUMNS, | |
| "institutional_available", | |
| "institutional_source", | |
| "institutional_as_of", | |
| "institutional_carry_forward", | |
| ] | |
| def connect(db_path: str | Path) -> sqlite3.Connection: | |
| """Open a SQLite connection with dict-like row access.""" | |
| Path(db_path).parent.mkdir(parents=True, exist_ok=True) | |
| conn = sqlite3.connect(db_path) | |
| conn.row_factory = sqlite3.Row | |
| return conn | |
| def init_db(db: str | Path | sqlite3.Connection) -> sqlite3.Connection: | |
| """Create cache tables if needed and return an initialized connection.""" | |
| conn = db if isinstance(db, sqlite3.Connection) else connect(db) | |
| conn.execute( | |
| f""" | |
| CREATE TABLE IF NOT EXISTS {DAILY_TABLE} ( | |
| date TEXT NOT NULL, | |
| stock_no TEXT NOT NULL, | |
| foreign_buy INTEGER NOT NULL DEFAULT 0, | |
| foreign_sell INTEGER NOT NULL DEFAULT 0, | |
| foreign_net INTEGER NOT NULL DEFAULT 0, | |
| trust_buy INTEGER NOT NULL DEFAULT 0, | |
| trust_sell INTEGER NOT NULL DEFAULT 0, | |
| trust_net INTEGER NOT NULL DEFAULT 0, | |
| dealer_buy INTEGER NOT NULL DEFAULT 0, | |
| dealer_sell INTEGER NOT NULL DEFAULT 0, | |
| dealer_net INTEGER NOT NULL DEFAULT 0, | |
| institutional_buy INTEGER NOT NULL DEFAULT 0, | |
| institutional_sell INTEGER NOT NULL DEFAULT 0, | |
| institutional_net INTEGER NOT NULL DEFAULT 0, | |
| institutional_available INTEGER NOT NULL DEFAULT 1, | |
| institutional_source TEXT NOT NULL DEFAULT '', | |
| institutional_as_of TEXT, | |
| institutional_carry_forward INTEGER NOT NULL DEFAULT 0, | |
| updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| PRIMARY KEY (stock_no, date) | |
| ) | |
| """ | |
| ) | |
| conn.execute( | |
| f""" | |
| CREATE TABLE IF NOT EXISTS {STATUS_TABLE} ( | |
| source TEXT NOT NULL, | |
| date TEXT NOT NULL, | |
| status TEXT NOT NULL, | |
| fetched_at TEXT NOT NULL, | |
| row_count INTEGER NOT NULL DEFAULT 0, | |
| error TEXT NOT NULL DEFAULT '', | |
| PRIMARY KEY (source, date) | |
| ) | |
| """ | |
| ) | |
| conn.execute( | |
| f"CREATE INDEX IF NOT EXISTS idx_{DAILY_TABLE}_date ON {DAILY_TABLE}(date)" | |
| ) | |
| conn.commit() | |
| return conn | |
| def _iso_date(value: Any) -> str: | |
| if hasattr(value, "isoformat"): | |
| return value.isoformat() | |
| return str(value).strip() | |
| def _int_value(value: Any, default: int = 0) -> int: | |
| if value is None: | |
| return default | |
| if isinstance(value, bool): | |
| return int(value) | |
| try: | |
| return int(value) | |
| except (TypeError, ValueError): | |
| return default | |
| def _row_values(row: dict[str, Any]) -> dict[str, Any]: | |
| values: dict[str, Any] = { | |
| "date": _iso_date(row["date"]), | |
| "stock_no": str(row["stock_no"]).replace(".TW", "").replace(".TWO", "").strip(), | |
| "institutional_available": _int_value(row.get("institutional_available", 1), 1), | |
| "institutional_source": str(row.get("institutional_source") or ""), | |
| "institutional_as_of": ( | |
| _iso_date(row["institutional_as_of"]) | |
| if row.get("institutional_as_of") is not None | |
| else None | |
| ), | |
| "institutional_carry_forward": _int_value(row.get("institutional_carry_forward", 0)), | |
| } | |
| for column in FLOW_COLUMNS: | |
| values[column] = _int_value(row.get(column, 0)) | |
| return values | |
| def upsert_daily_rows( | |
| db: str | Path | sqlite3.Connection, | |
| rows: Iterable[dict[str, Any]], | |
| ) -> int: | |
| """Insert or replace institutional daily rows; return rows written.""" | |
| conn = init_db(db) | |
| prepared = [_row_values(row) for row in rows] | |
| if not prepared: | |
| return 0 | |
| placeholders = ", ".join(f":{column}" for column in DAILY_COLUMNS) | |
| updates = ", ".join( | |
| f"{column}=excluded.{column}" | |
| for column in DAILY_COLUMNS | |
| if column not in {"date", "stock_no"} | |
| ) | |
| conn.executemany( | |
| f""" | |
| INSERT INTO {DAILY_TABLE} ({", ".join(DAILY_COLUMNS)}) | |
| VALUES ({placeholders}) | |
| ON CONFLICT(stock_no, date) DO UPDATE SET | |
| {updates}, | |
| updated_at=CURRENT_TIMESTAMP | |
| """, | |
| prepared, | |
| ) | |
| conn.commit() | |
| return len(prepared) | |
| def fetch_daily_rows( | |
| db: str | Path | sqlite3.Connection, | |
| stock_no: str, | |
| start_date: Any, | |
| end_date: Any, | |
| ) -> list[dict[str, Any]]: | |
| """Fetch cached institutional rows for one stock/date range, ascending.""" | |
| conn = init_db(db) | |
| code = str(stock_no).replace(".TW", "").replace(".TWO", "").strip() | |
| cursor = conn.execute( | |
| f""" | |
| SELECT {", ".join(DAILY_COLUMNS)} | |
| FROM {DAILY_TABLE} | |
| WHERE stock_no = ? AND date >= ? AND date <= ? | |
| ORDER BY date ASC | |
| """, | |
| (code, _iso_date(start_date), _iso_date(end_date)), | |
| ) | |
| return [_decode_daily_row(row) for row in cursor.fetchall()] | |
| def fetch_recent_rows_by_code( | |
| db: str | Path | sqlite3.Connection = DEFAULT_CACHE_PATH, | |
| *, | |
| codes: Iterable[str] | None = None, | |
| lookback_days: int = 120, | |
| end_date: Any | None = None, | |
| ) -> dict[str, list[dict[str, Any]]]: | |
| """ | |
| Return recent institutional rows grouped by stock code. | |
| This helper intentionally uses trading rows rather than calendar-day math: | |
| it first finds the latest available cache dates, then returns rows whose | |
| date is inside that latest-N trading-date set. The hotspot scanner can use | |
| it without issuing TWSE/TPEx requests at request time. | |
| """ | |
| conn = init_db(db) | |
| params: list[Any] = [] | |
| where = "" | |
| if end_date is not None: | |
| where = "WHERE date <= ?" | |
| params.append(_iso_date(end_date)) | |
| date_rows = conn.execute( | |
| f""" | |
| SELECT DISTINCT date | |
| FROM {DAILY_TABLE} | |
| {where} | |
| ORDER BY date DESC | |
| LIMIT ? | |
| """, | |
| (*params, max(1, int(lookback_days))), | |
| ).fetchall() | |
| dates = sorted(row["date"] for row in date_rows) | |
| if not dates: | |
| return {} | |
| code_list = [ | |
| str(code).replace(".TW", "").replace(".TWO", "").strip() | |
| for code in (codes or []) | |
| if str(code).strip() | |
| ] | |
| query_params: list[Any] = [dates[0], dates[-1]] | |
| code_clause = "" | |
| if code_list: | |
| placeholders = ", ".join("?" for _ in code_list) | |
| code_clause = f" AND stock_no IN ({placeholders})" | |
| query_params.extend(code_list) | |
| cursor = conn.execute( | |
| f""" | |
| SELECT {", ".join(DAILY_COLUMNS)} | |
| FROM {DAILY_TABLE} | |
| WHERE date >= ? AND date <= ? {code_clause} | |
| ORDER BY stock_no ASC, date ASC | |
| """, | |
| query_params, | |
| ) | |
| grouped: dict[str, list[dict[str, Any]]] = {} | |
| valid_dates = set(dates) | |
| for row in cursor.fetchall(): | |
| decoded = _decode_daily_row(row) | |
| if decoded["date"] not in valid_dates: | |
| continue | |
| grouped.setdefault(decoded["stock_no"], []).append(decoded) | |
| return grouped | |
| def _decode_daily_row(row: sqlite3.Row) -> dict[str, Any]: | |
| data = dict(row) | |
| data["institutional_available"] = bool(data["institutional_available"]) | |
| data["institutional_carry_forward"] = bool(data["institutional_carry_forward"]) | |
| return data | |
| def upsert_fetch_status( | |
| db: str | Path | sqlite3.Connection, | |
| source: str, | |
| date: Any, | |
| status: str, | |
| *, | |
| row_count: int = 0, | |
| error: str = "", | |
| fetched_at: str | None = None, | |
| ) -> None: | |
| """Record the fetch outcome for a source/date pair.""" | |
| conn = init_db(db) | |
| conn.execute( | |
| f""" | |
| INSERT INTO {STATUS_TABLE} | |
| (source, date, status, fetched_at, row_count, error) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| ON CONFLICT(source, date) DO UPDATE SET | |
| status=excluded.status, | |
| fetched_at=excluded.fetched_at, | |
| row_count=excluded.row_count, | |
| error=excluded.error | |
| """, | |
| ( | |
| source, | |
| _iso_date(date), | |
| status, | |
| fetched_at or datetime.now(timezone.utc).isoformat(), | |
| _int_value(row_count), | |
| error or "", | |
| ), | |
| ) | |
| conn.commit() | |
| def get_fetch_status( | |
| db: str | Path | sqlite3.Connection, | |
| source: str, | |
| date: Any, | |
| ) -> dict[str, Any] | None: | |
| """Return the fetch status for a source/date pair, if present.""" | |
| conn = init_db(db) | |
| row = conn.execute( | |
| f""" | |
| SELECT source, date, status, fetched_at, row_count, error | |
| FROM {STATUS_TABLE} | |
| WHERE source = ? AND date = ? | |
| """, | |
| (source, _iso_date(date)), | |
| ).fetchone() | |
| return dict(row) if row is not None else None | |