File size: 8,043 Bytes
cdead71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee37d63
 
 
 
cdead71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8dd75fe
 
cdead71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee37d63
 
cdead71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354f67f
cdead71
 
 
 
 
 
354f67f
cdead71
 
 
 
 
354f67f
 
 
 
cdead71
354f67f
cdead71
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""
Taiwan margin trading (融資融券) data fetcher.

Source: TWSE MI_MARGN API (TWSE-listed stocks only; OTC has no equivalent public endpoint).

Derived ML features (added via add_margin_flow):
  margin_balance        raw daily margin balance (trading units = 1000 shares each)
  short_balance         raw daily short balance (trading units)
  short_margin_ratio    short_balance / margin_balance — 券資比 (OTC-style pressure gauge)
  margin_5d_change_pct  5-day % change in margin_balance (rising = retail adding leverage)
  margin_buy_pressure   margin_buy / (margin_buy + margin_sell) — direction of margin flow
  chip_squeeze_signal   foreign_net > 0 AND margin_5d_change_pct > 0.05 (squeeze setup)
"""

from __future__ import annotations

import logging
import os
from datetime import date, timedelta

import pandas as pd
import requests
from cachetools import TTLCache

logger = logging.getLogger(__name__)

TWSE_MARGN_URL = "https://www.twse.com.tw/rwd/zh/marginTrading/MI_MARGN"

MARGIN_COLUMNS = [
    "margin_balance",
    "short_balance",
    "margin_buy",
    "margin_sell",
    "margin_prev_balance",
    "short_prev_balance",
]

# 6h TTL — published once per day, no point refreshing more often
_DAILY_CACHE: TTLCache = TTLCache(maxsize=400, ttl=6 * 60 * 60)
_FLOW_CACHE: TTLCache = TTLCache(maxsize=100, ttl=30 * 60)

_HEADERS = {"User-Agent": "Mozilla/5.0"}


def _margin_enabled() -> bool:
    return os.getenv("ENABLE_MARGIN_FLOW", "1").strip().lower() not in {"0", "false", "no", "off"}


def _margin_fetch_warnings_enabled() -> bool:
    return os.getenv("ENABLE_MARGIN_FETCH_WARNINGS", "0").strip().lower() in {"1", "true", "yes", "on"}


def _parse_int(value) -> int:
    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 _neutral_row(stock_no: str, date_str: str) -> dict:
    row = {"date": date_str, "stock_no": stock_no, "margin_available": False}
    row.update({col: 0 for col in MARGIN_COLUMNS})
    row["short_buy"] = 0
    row["short_sell"] = 0
    return row


def _normalize_code(stock_no: str) -> str:
    return stock_no.replace(".TW", "").replace(".TWO", "").strip()


def parse_twse_margn(payload: dict, date_str: str) -> dict[str, dict]:
    """
    Parse TWSE MI_MARGN JSON.

    tables[1] = 融資融券彙總 with columns:
      0  代號  1  名稱
      2  融資買進  3  融資賣出  4  現金償還  5  前日餘額  6  今日餘額  7  次日限額
      8  融券買進  9  融券賣出  10 現券償還  11 前日餘額  12 今日餘額  13 次日限額
      14 資券互抵  15 註記
    """
    tables = payload.get("tables") or []
    if len(tables) < 2:
        return {}

    rows: dict[str, dict] = {}
    for raw in tables[1].get("data", []) or []:
        if len(raw) < 13:
            continue
        code = _normalize_code(str(raw[0]))
        rows[code] = {
            "date": date_str,
            "stock_no": code,
            "margin_buy": _parse_int(raw[2]),
            "margin_sell": _parse_int(raw[3]),
            "margin_prev_balance": _parse_int(raw[5]),
            "margin_balance": _parse_int(raw[6]),
            "short_buy": _parse_int(raw[8]),
            "short_sell": _parse_int(raw[9]),
            "short_prev_balance": _parse_int(raw[11]),
            "short_balance": _parse_int(raw[12]),
            "margin_available": True,
        }
    return rows


def _fetch_daily(date_str: str) -> dict[str, dict]:
    """Fetch and cache all margin rows for one date."""
    cached = _DAILY_CACHE.get(date_str)
    if cached is not None:
        return cached

    try:
        resp = requests.get(
            TWSE_MARGN_URL,
            params={"response": "json", "date": date_str.replace("-", ""), "selectType": "ALL"},
            headers=_HEADERS,
            timeout=10,
        )
        resp.raise_for_status()
        rows = parse_twse_margn(resp.json(), date_str)
    except Exception as exc:
        log = logger.warning if _margin_fetch_warnings_enabled() else logger.debug
        log("margin fetch failed for %s: %s", date_str, exc)
        rows = {}

    _DAILY_CACHE[date_str] = rows
    return rows


def _find_nearest_published(date_str: str, lookback: int = 5) -> dict[str, dict]:
    """Walk back up to `lookback` trading days to find a published margin dataset."""
    dt = date.fromisoformat(date_str)
    for _ in range(lookback):
        rows = _fetch_daily(dt.strftime("%Y-%m-%d"))
        if rows:
            return rows
        dt -= timedelta(days=1)
    return {}


def fetch_margin_flow(
    stock_no: str,
    dates: list[str] | pd.Series | pd.Index,
    *,
    max_days: int = 260,
) -> pd.DataFrame:
    """
    Return daily margin rows aligned to `dates`.
    OTC stocks (TPEX) get neutral rows — TWSE doesn't publish their margin data.
    """
    bare = _normalize_code(stock_no)
    if not _margin_enabled() or max_days <= 0:
        return pd.DataFrame([_neutral_row(bare, str(d)) for d in dates])

    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()

    cache_key = f"margin:{bare}:{date_strings[-1]}:{len(date_strings)}"
    cached = _FLOW_CACHE.get(cache_key)
    if cached is not None:
        return cached.copy()

    selected = date_strings[-max_days:]
    older = date_strings[: max(0, len(date_strings) - len(selected))]
    rows = [_neutral_row(bare, d) for d in older]

    for date_str in selected:
        daily = _fetch_daily(date_str)
        if bare in daily:
            rows.append(daily[bare])
        else:
            rows.append(_neutral_row(bare, date_str))

    df = pd.DataFrame(rows).sort_values("date").reset_index(drop=True)

    # Carry-forward: if latest day not yet published, use most recent published row
    if not df.empty and not bool(df.iloc[-1].get("margin_available", False)):
        prior = df[df["margin_available"] == True]  # noqa: E712
        if not prior.empty:
            prior_row = prior.iloc[-1]
            last_idx = df.index[-1]
            for col in MARGIN_COLUMNS:
                df.at[last_idx, col] = prior_row[col]
            df.at[last_idx, "margin_available"] = True

    _FLOW_CACHE[cache_key] = df.copy()
    return df


def add_margin_flow(df: pd.DataFrame, stock_no: str, *, max_days: int = 260) -> pd.DataFrame:
    """Merge margin columns into an OHLCV/indicator DataFrame."""
    if df.empty or "date" not in df.columns:
        return df

    out = df.copy()

    # OTC stocks: no TWSE margin data, return neutral zeros
    from data.fetcher import detect_exchange
    if detect_exchange(_normalize_code(stock_no)) == "TPEX":
        for col in MARGIN_COLUMNS + ["short_buy", "short_sell"]:
            out[col] = 0.0
        out["margin_available"] = False
        return out

    flow = fetch_margin_flow(stock_no, out["date"], max_days=max_days)
    if flow.empty:
        for col in MARGIN_COLUMNS + ["short_buy", "short_sell"]:
            out[col] = 0.0
        out["margin_available"] = False
        return out

    out["_mdate"] = pd.to_datetime(out["date"]).dt.strftime("%Y-%m-%d")
    flow_cols = ["date", "margin_available"] + MARGIN_COLUMNS + ["short_buy", "short_sell"]
    for c in ["short_buy", "short_sell"]:
        if c not in flow.columns:
            flow[c] = 0
    merged = out.merge(
        flow[[c for c in flow_cols if c in flow.columns]],
        how="left",
        left_on="_mdate",
        right_on="date",
        suffixes=("", "_mflow"),
    )
    merged = merged.drop(columns=["_mdate"] + [c for c in ["date_mflow"] if c in merged.columns])
    for col in MARGIN_COLUMNS + ["short_buy", "short_sell"]:
        merged[col] = pd.to_numeric(merged.get(col, 0), errors="coerce").fillna(0).astype(float)
    merged["margin_available"] = merged.get("margin_available", False).fillna(False).astype(bool)
    return merged