File size: 23,707 Bytes
457ced4
 
 
 
 
 
 
ee37d63
 
 
 
457ced4
 
 
 
 
b72deed
 
457ced4
 
 
 
 
 
 
0feab1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457ced4
0feab1a
457ced4
 
 
 
 
0feab1a
 
 
 
7e72898
 
 
 
 
0feab1a
457ced4
 
 
 
 
 
 
 
 
e9da7eb
7e72898
 
 
e9da7eb
 
7e72898
 
 
 
 
e9da7eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457ced4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee37d63
 
 
 
 
 
 
 
 
 
457ced4
ee37d63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4c5483
457ced4
 
 
ee37d63
 
 
 
 
 
 
 
 
 
457ced4
 
 
 
 
 
 
 
 
 
 
 
b72deed
 
 
e9da7eb
 
 
 
 
 
 
 
 
 
457ced4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fef111
 
457ced4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b72deed
 
 
e9da7eb
 
 
 
 
 
 
 
 
 
 
457ced4
 
 
47023f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457ced4
 
 
 
 
 
 
 
 
0feab1a
 
 
 
 
b4eaf42
 
0feab1a
 
e6131ae
 
0feab1a
e6131ae
 
0feab1a
e6131ae
 
 
 
 
 
 
 
 
0feab1a
 
 
 
 
 
 
 
 
 
 
 
 
 
457ced4
b72deed
 
e6131ae
0feab1a
 
 
b72deed
e9da7eb
 
e6131ae
 
0feab1a
e6131ae
 
0feab1a
e6131ae
 
 
 
 
0feab1a
e6131ae
 
 
 
 
 
 
0feab1a
e6131ae
 
 
 
 
 
 
 
 
 
 
 
 
0feab1a
e6131ae
 
 
 
 
 
 
0feab1a
e6131ae
0feab1a
 
 
 
 
 
 
e6131ae
 
 
 
457ced4
e6131ae
 
 
 
 
 
 
 
 
 
 
 
0feab1a
e6131ae
 
 
 
 
 
 
0feab1a
e6131ae
0feab1a
 
 
 
457ced4
e6131ae
0feab1a
e6131ae
 
 
 
 
 
457ced4
 
 
 
 
 
c4c5483
 
 
9454766
c4c5483
 
 
 
9563b55
9454766
9563b55
 
 
c4c5483
 
9454766
 
 
 
 
457ced4
 
9454766
 
 
457ced4
9454766
457ced4
 
9454766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457ced4
 
9454766
 
 
457ced4
9454766
 
 
 
457ced4
 
 
 
e9da7eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb9657d
 
 
 
 
 
 
 
 
 
 
b250588
 
bb9657d
 
b250588
 
bb9657d
 
 
 
 
 
 
 
 
 
 
b250588
 
bb9657d
 
b250588
bb9657d
 
 
 
 
 
 
 
 
 
 
b250588
 
 
 
bb9657d
 
 
b250588
bb9657d
 
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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
"""
Data fetcher using yfinance (Yahoo Finance) β€” supports all Taiwan stocks
with .TW (TWSE) and .TWO (TPEX/OTC) suffixes, matching the data shown on
https://tw.stock.yahoo.com/quote/7856.TWO
"""

import logging
import pickle
import subprocess
import sys
from pathlib import Path
from typing import Optional

import pandas as pd
import yfinance as yf

from data.fund_fetcher import is_moneydj_fund, fetch_fund_history, get_fund_info as _get_fund_info_moneydj

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Exchange detection
# ---------------------------------------------------------------------------

_exchange_map: dict[str, str] | None = None


def _build_exchange_map() -> dict[str, str]:
    """Build a stock_no -> exchange mapping from twstock.codes (cached)."""
    global _exchange_map
    if _exchange_map is not None:
        return _exchange_map
    codes = _get_twstock_codes()
    if not codes:
        _exchange_map = {}
        return _exchange_map
    mapping: dict[str, str] = {}
    for code, info in codes.items():
        market = getattr(info, "market", "")
        if market in ("δΈŠζ«ƒ", "θˆˆζ«ƒ"):
            mapping[code] = "TPEX"
        else:
            mapping[code] = "TWSE"
    _exchange_map = mapping
    return _exchange_map


def detect_exchange(stock_no: str) -> str:
    """Return 'TPEX' or 'TWSE' based on twstock.codes, suffix, or heuristic."""
    if stock_no.endswith(".TWO"):
        return "TPEX"
    if stock_no.endswith(".TW"):
        return "TWSE"
    bare = stock_no.strip()
    # Exact lookup via twstock.codes β€” avoids heuristic misses like 4169
    exchange_map = _build_exchange_map()
    if bare in exchange_map:
        return exchange_map[bare]
    # TW ETF with letter suffix (e.g. 00772B) or 6-digit code β€” always TWSE
    if bare and bare[0].isdigit() and any(c.isalpha() for c in bare):
        return "TWSE"
    if len(bare) == 6 and bare.isdigit():
        return "TWSE"
    # Fallback heuristic for stocks not in twstock database
    if len(bare) == 5 and bare.isdigit():
        return "TPEX"
    if len(bare) == 4 and bare.isdigit():
        v = int(bare)
        if 6000 <= v <= 9999:
            return "TPEX"
    return "TWSE"


def is_us_ticker(stock_no: str) -> bool:
    """Return True if this looks like a US stock/ETF ticker (contains letters, no TW suffix).
    Taiwan ETF codes starting with digits (e.g. 00772B, 006206) are NOT US tickers.
    """
    if stock_no.endswith(".TW") or stock_no.endswith(".TWO"):
        return False
    bare = stock_no.strip()
    # Any code starting with a digit is a Taiwan stock/ETF (may have letter suffix like 00772B)
    if bare and bare[0].isdigit():
        return False
    return any(c.isalpha() for c in bare)


def _normalize_raw_df(raw: pd.DataFrame) -> pd.DataFrame:
    """Flatten and normalize a raw yfinance DataFrame to standard OHLCV format."""
    if isinstance(raw.columns, pd.MultiIndex):
        raw.columns = [col[0].lower() if isinstance(col, tuple) else col.lower()
                       for col in raw.columns]
    else:
        raw.columns = [c.lower() for c in raw.columns]

    df = raw.reset_index()
    df = df.rename(columns={"Date": "date", "index": "date"})
    df["date"] = pd.to_datetime(df["date"]).dt.date

    col_map = {}
    for col in df.columns:
        cl = col.lower()
        if cl == "date":
            col_map[col] = "date"
        elif cl in ("close", "adj close", "adj_close"):
            col_map[col] = "close"
        elif cl == "open":
            col_map[col] = "open"
        elif cl == "high":
            col_map[col] = "high"
        elif cl == "low":
            col_map[col] = "low"
        elif cl == "volume":
            col_map[col] = "volume"

    df = df.rename(columns=col_map)
    needed = ["date", "open", "high", "low", "close", "volume"]
    df = df[[c for c in needed if c in df.columns]]
    df = df.dropna(subset=["close"])
    df = df.sort_values("date").reset_index(drop=True)
    df = df.drop_duplicates(subset=["date"])
    for col in ["open", "high", "low", "close", "volume"]:
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors="coerce")
    return df


def _to_yf_ticker(stock_no: str) -> str:
    """
    Convert bare stock number or suffixed symbol to yfinance ticker.
    Examples:
        '7856'      -> '7856.TWO'  (TPEX heuristic)
        '7856.TWO'  -> '7856.TWO'
        '2330.TW'   -> '2330.TW'
        '2330'      -> '2330.TW'   (TWSE heuristic)
    """
    if stock_no.endswith(".TW") or stock_no.endswith(".TWO"):
        return stock_no
    exchange = detect_exchange(stock_no)
    suffix = ".TWO" if exchange == "TPEX" else ".TW"
    return stock_no + suffix


# ---------------------------------------------------------------------------
# History fetcher
# ---------------------------------------------------------------------------

def _download_raw(
    ticker: str,
    period: str,
    *,
    interval: str = "1d",
    timeout_seconds: int = 20,
) -> pd.DataFrame:
    """Download from yfinance; terminate blocked SSL reads after a hard timeout."""

    worker = Path(__file__).with_name("yfinance_download_worker.py")
    try:
        completed = subprocess.run(
            [
                sys.executable,
                str(worker),
                "--ticker",
                ticker,
                "--period",
                period,
                "--interval",
                interval,
                "--request-timeout",
                str(max(1, timeout_seconds - 3)),
            ],
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout_seconds,
        )
    except subprocess.TimeoutExpired:
        logger.warning("yfinance download hard timed out for %s (%ss)", ticker, timeout_seconds)
        return pd.DataFrame()
    except Exception as exc:
        logger.warning("yfinance download failed for %s: %s", ticker, exc)
        return pd.DataFrame()
    if completed.returncode != 0:
        detail = completed.stderr.decode("utf-8", errors="replace").strip()
        logger.warning("yfinance download failed for %s: %s", ticker, detail or f"worker exit {completed.returncode}")
        return pd.DataFrame()
    try:
        raw = pickle.loads(completed.stdout)
    except Exception as exc:
        logger.warning("yfinance download decode failed for %s: %s", ticker, exc)
        return pd.DataFrame()
    return raw if raw is not None else pd.DataFrame()


def fetch_history(stock_no: str, months: int = 24) -> pd.DataFrame:
    """
    Fetch historical daily OHLCV for a Taiwan stock via Yahoo Finance.
    Tries the heuristic exchange first, then falls back to the other exchange
    so stocks like 4169 (TPEX) are found even when detected as TWSE.

    Returns a DataFrame with columns:
        date (datetime.date), open, high, low, close, volume (float)
    Sorted ascending by date, no duplicates.
    """
    if is_moneydj_fund(stock_no):
        return fetch_fund_history(stock_no, months)

    if is_us_ticker(stock_no):
        ticker = stock_no.upper().strip()
        period = f"{months}mo"
        raw = _download_raw(ticker, period)
        if raw.empty:
            raise ValueError(f"No data found for {ticker}")
        df = _normalize_raw_df(raw)
        logger.info("Fetched %d rows for US ticker %s", len(df), ticker)
        return df

    bare = stock_no.replace(".TWO", "").replace(".TW", "")
    period = f"{months}mo"

    # Build ordered list of tickers to try: heuristic first, then the other
    primary = _to_yf_ticker(stock_no)
    alt = bare + (".TW" if primary.endswith(".TWO") else ".TWO")
    tickers_to_try = [primary, alt]

    raw = pd.DataFrame()
    used_ticker = primary
    for ticker in tickers_to_try:
        raw = _download_raw(ticker, period)
        if not raw.empty:
            used_ticker = ticker
            logger.info("Fetched data for %s using ticker %s", stock_no, ticker)
            break

    if raw.empty:
        raise ValueError(
            f"No data found for stock {bare} (.TW or .TWO). "
            "The stock may be delisted, newly listed, or the number is incorrect."
        )

    df = _normalize_raw_df(raw)
    logger.info("Fetched %d rows for %s", len(df), used_ticker)
    return df


# ---------------------------------------------------------------------------
# Stock info helpers
# ---------------------------------------------------------------------------

def _resolve_ticker(stock_no: str) -> tuple[str, str]:
    """
    Return (ticker, exchange) by trying both .TWO and .TW suffixes.
    Uses whichever has live price data.
    """
    bare = stock_no.replace(".TWO", "").replace(".TW", "")
    primary = _to_yf_ticker(stock_no)
    alt = bare + (".TW" if primary.endswith(".TWO") else ".TWO")

    for ticker in [primary, alt]:
        try:
            info = yf.Ticker(ticker).info
            if info.get("regularMarketPrice") or info.get("previousClose") or info.get("currentPrice"):
                exchange = "TPEX" if ticker.endswith(".TWO") else "TWSE"
                return ticker, exchange
        except Exception:
            pass
    return primary, detect_exchange(stock_no)


def get_stock_info(stock_no: str) -> dict:
    """
    Return basic stock info: name, exchange, stock_no.
    Uses yfinance Ticker.info for metadata, with exchange auto-detection fallback.
    """
    if is_moneydj_fund(stock_no):
        return _get_fund_info_moneydj(stock_no)

    if is_us_ticker(stock_no):
        ticker = stock_no.upper().strip()
        try:
            info = yf.Ticker(ticker).info
            name = info.get("longName") or info.get("shortName") or ticker
            exchange = info.get("exchange") or "US"
            return {"stock_no": ticker, "name": name, "exchange": exchange, "ticker": ticker}
        except Exception as exc:
            logger.warning("Could not fetch info for %s: %s", ticker, exc)
            return {"stock_no": ticker, "name": ticker, "exchange": "US", "ticker": ticker}

    bare = stock_no.replace(".TWO", "").replace(".TW", "")
    ticker, exchange = _resolve_ticker(stock_no)

    # Try twstock first (has accurate Chinese names)
    name = bare
    codes = _get_twstock_codes()
    if bare in codes:
        name = codes[bare].name

    # If no Chinese name found, fall back to yfinance (English name)
    if name == bare:
        try:
            info = yf.Ticker(ticker).info
            name = (
                info.get("longName")
                or info.get("shortName")
                or info.get("displayName")
                or bare
            )
        except Exception as exc:
            logger.warning("Could not fetch yfinance info for %s: %s", ticker, exc)

    return {
        "stock_no": bare,
        "name": name,
        "exchange": exchange,
        "ticker": ticker,
    }


# ---------------------------------------------------------------------------
# Realtime quote cache β€” stores last successful quote per stock so that
# non-trading-hours requests (twstock price=None) return stale but valid data.
# ---------------------------------------------------------------------------

from cachetools import TTLCache
_realtime_cache: TTLCache = TTLCache(maxsize=50, ttl=30)


def get_realtime_quote(stock_no: str) -> dict:
    """
    Return real-time quote dict: {price, change, change_pct, open, high, low, source, is_realtime}.
    For Taiwan stocks: uses twstock.realtime (direct TWSE/TPEX API), falls back to yfinance.
    For US stocks: uses yfinance 5-day download (last 2 rows β†’ today's change).
    Returns cached stale data (with is_realtime=False) during non-trading hours.
    Returns empty dict on complete failure.
    """
    def _safe_float(s) -> Optional[float]:
        try:
            v = float(s)
            return v if v > 0 else None
        except (TypeError, ValueError):
            return None

    def _cache_and_return(key: str, result: dict, is_realtime: bool = True) -> dict:
        result["is_realtime"] = is_realtime
        if result.get("price") is not None:
            _realtime_cache[key] = result
        return result

    def _get_stale(key: str) -> dict:
        cached = _realtime_cache.get(key)
        if cached:
            stale = dict(cached)
            stale["is_realtime"] = False
            return stale
        return {}

    try:
        if is_moneydj_fund(stock_no):
            info = _get_fund_info_moneydj(stock_no)
            price = info.get("current_price")
            if price:
                return _cache_and_return(stock_no, {"price": price, "change": None, "change_pct": None, "source": "moneydj"})
            return _get_stale(stock_no) or {}

        if is_us_ticker(stock_no):
            ticker = stock_no.upper().strip()
            raw = _download_raw(ticker, "5d")
            if raw.empty:
                return _get_stale(ticker) or {}
            df = _normalize_raw_df(raw)
            if len(df) < 2:
                return _get_stale(ticker) or {}
            today = float(df.iloc[-1]["close"])
            yesterday = float(df.iloc[-2]["close"])
            change = today - yesterday
            change_pct = change / yesterday * 100 if yesterday else 0
            row = df.iloc[-1]
            return _cache_and_return(ticker, {
                "price": today,
                "change": round(change, 2),
                "change_pct": round(change_pct, 2),
                "open": float(row["open"]) if "open" in row else None,
                "high": float(row["high"]) if "high" in row else None,
                "low": float(row["low"]) if "low" in row else None,
                "source": "yfinance",
            })

        # Taiwan stock β€” try twstock.realtime first
        bare = stock_no.replace(".TWO", "").replace(".TW", "")
        try:
            import twstock
            data = twstock.realtime.get(bare)
            if data and data.get("success"):
                rt = data.get("realtime", {})
                price = _safe_float(rt.get("latest_trade_price"))
                yest  = _safe_float(rt.get("yesterday_price"))
                if price and yest:
                    change = round(price - yest, 2)
                    change_pct = round(change / yest * 100, 2)
                    return _cache_and_return(bare, {
                        "price": price,
                        "change": change,
                        "change_pct": change_pct,
                        "open": _safe_float(rt.get("open")),
                        "high": _safe_float(rt.get("high")),
                        "low": _safe_float(rt.get("low")),
                        "source": "twstock",
                    })
                elif price:
                    return _cache_and_return(bare, {"price": price, "change": None, "change_pct": None, "source": "twstock"})
                else:
                    # Non-trading hours: price is None β€” return cached stale data
                    stale = _get_stale(bare)
                    if stale:
                        logger.info("twstock price=None for %s (non-trading hours), using cached quote", bare)
                        return stale
        except Exception as exc:
            logger.warning("twstock.realtime failed for %s: %s", bare, exc)

        # Fallback: yfinance 5-day download
        ticker = _to_yf_ticker(stock_no)
        raw = _download_raw(ticker, "5d")
        if raw.empty:
            alt = bare + (".TW" if ticker.endswith(".TWO") else ".TWO")
            raw = _download_raw(alt, "5d")
        if not raw.empty:
            df = _normalize_raw_df(raw)
            if len(df) >= 2:
                today = float(df.iloc[-1]["close"])
                yesterday = float(df.iloc[-2]["close"])
                change = today - yesterday
                change_pct = change / yesterday * 100 if yesterday else 0
                row = df.iloc[-1]
                return _cache_and_return(bare, {
                    "price": today,
                    "change": round(change, 2),
                    "change_pct": round(change_pct, 2),
                    "open": float(row["open"]) if "open" in row else None,
                    "high": float(row["high"]) if "high" in row else None,
                    "low": float(row["low"]) if "low" in row else None,
                    "source": "yfinance",
                })
            elif len(df) == 1:
                return _cache_and_return(bare, {"price": float(df.iloc[-1]["close"]), "change": None, "change_pct": None, "source": "yfinance"})

        # All live sources failed β€” try stale cache
        return _get_stale(bare)
    except Exception as exc:
        logger.warning("get_realtime_quote error for %s: %s", stock_no, exc)
    return _get_stale(stock_no.replace(".TWO", "").replace(".TW", ""))


def get_current_price(stock_no: str) -> Optional[float]:
    """Return the most recent closing price (delegates to get_realtime_quote)."""
    q = get_realtime_quote(stock_no)
    return q.get("price") if q else None


# ---------------------------------------------------------------------------
# Search helper  (TPEX + TWSE company lists via yfinance lookup)
# ---------------------------------------------------------------------------

_twstock_codes_cache = None


def _get_twstock_codes():
    """Lazily load twstock codes dict; cached globally after first call."""
    global _twstock_codes_cache
    if _twstock_codes_cache is not None:
        return _twstock_codes_cache
    import concurrent.futures
    try:
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
            future = executor.submit(__import__, "twstock")
            twstock = future.result(timeout=5)
            _twstock_codes_cache = twstock.codes
            return _twstock_codes_cache
    except Exception as exc:
        logger.warning("twstock unavailable: %s", exc)
        return {}


def search_stocks(query: str) -> list[dict]:
    """
    Search Taiwan stocks by Chinese name or stock number.
    Uses twstock local database for Chinese name matching (fast, accurate),
    falls back to yfinance for English / number queries.
    """
    q = query.strip()
    results = []

    # ── Chinese name search via twstock ──────────────────────────────────────
    has_chinese = any('\u4e00' <= c <= '\u9fff' for c in q)
    if has_chinese:
        codes = _get_twstock_codes()
        VALID_TYPES = {'θ‚‘η₯¨', 'ETF'}
        q_lower = q.lower()
        for code, info in codes.items():
            if info.type not in VALID_TYPES:
                continue
            if q_lower not in info.name.lower() and q_lower not in code.lower():
                continue
            exchange = 'TPEX' if info.market in ('δΈŠζ«ƒ', 'θˆˆζ«ƒ') else 'TWSE'
            results.append({'stock_no': code, 'name': info.name, 'exchange': exchange})
            if len(results) >= 20:
                break
        return results

    # ── Number search: try twstock first, then yfinance ───────────────────────
    if q.isdigit():
        codes = _get_twstock_codes()
        if q in codes:
            info = codes[q]
            exchange = 'TPEX' if info.market in ('δΈŠζ«ƒ', 'θˆˆζ«ƒ') else 'TWSE'
            results.append({'stock_no': q, 'name': info.name, 'exchange': exchange})
        if not results:
            for suffix, exchange in [('.TWO', 'TPEX'), ('.TW', 'TWSE')]:
                try:
                    import yfinance as yf
                    info_yf = yf.Ticker(q + suffix).info
                    if info_yf.get('regularMarketPrice') or info_yf.get('previousClose'):
                        name = info_yf.get('longName') or info_yf.get('shortName') or q
                        results.append({'stock_no': q, 'name': name, 'exchange': exchange})
                        break
                except Exception:
                    pass
        return results[:5]

    # ── English keyword: yfinance search ─────────────────────────────────────
    try:
        import yfinance as yf
        for item in yf.Search(q + ' Taiwan', max_results=10).quotes:
            sym = item.get('symbol', '')
            if not (sym.endswith('.TW') or sym.endswith('.TWO')):
                continue
            bare = sym.replace('.TWO', '').replace('.TW', '')
            exchange = 'TPEX' if sym.endswith('.TWO') else 'TWSE'
            name = item.get('longname') or item.get('shortname') or bare
            results.append({'stock_no': bare, 'name': name, 'exchange': exchange})
    except Exception as exc:
        logger.warning("yfinance search error: %s", exc)

    return results[:20]


def search_us(query: str) -> list[dict]:
    """Search US stocks and ETFs via yfinance."""
    try:
        results = []
        for item in yf.Search(query, max_results=15).quotes:
            sym = item.get("symbol", "")
            if not sym or sym.endswith(".TW") or sym.endswith(".TWO"):
                continue
            name = item.get("longname") or item.get("shortname") or sym
            exchange = item.get("exchange") or "US"
            results.append({"stock_no": sym, "name": name, "exchange": exchange})
        return results[:10]
    except Exception as exc:
        logger.warning("US search error: %s", exc)
        return []


# ---------------------------------------------------------------------------
# Cross-asset data for Taiwan stocks (TAIEX + USD/TWD)
# ---------------------------------------------------------------------------

_cross_asset_cache: dict[str, tuple] = {}  # key β†’ (series, fetched_at)
_CROSS_ASSET_CACHE_TTL = 3600  # 1 hour

def fetch_cross_asset_tw(start_date: str, end_date: str) -> tuple:
    """
    Fetch TAIEX (^TWII), USD/TWD (TWD=X), SOX (^SOX), and US 10Y yield (^TNX)
    daily close prices via yfinance.

    Returns:
        (taiex_close, usdtwd_close, sox_close, tnx_close) β€” pd.Series indexed
        by 'YYYY-MM-DD' strings. Any element may be None if the fetch fails.
    """
    import time as _time
    cache_key = f"{start_date}:{end_date}"
    cached = _cross_asset_cache.get(cache_key)
    if cached is not None:
        data, fetched_at = cached
        if _time.time() - fetched_at < _CROSS_ASSET_CACHE_TTL:
            return data

    taiex_close = None
    usdtwd_close = None
    sox_close = None
    tnx_close = None
    try:
        raw = yf.download(
            ["^TWII", "TWD=X", "^SOX", "^TNX"],
            start=start_date, end=end_date,
            auto_adjust=True, progress=False,
        )
        if not raw.empty:
            close = raw["Close"] if "Close" in raw.columns else raw.get("close")
            if close is not None and not close.empty:
                close.index = pd.to_datetime(close.index).strftime("%Y-%m-%d")
                if "^TWII" in close.columns:
                    taiex_close = close["^TWII"].dropna()
                if "TWD=X" in close.columns:
                    usdtwd_close = close["TWD=X"].dropna()
                if "^SOX" in close.columns:
                    sox_close = close["^SOX"].dropna()
                if "^TNX" in close.columns:
                    tnx_close = close["^TNX"].dropna()
    except Exception as exc:
        logger.warning("fetch_cross_asset_tw failed: %s", exc)

    result = (taiex_close, usdtwd_close, sox_close, tnx_close)
    _cross_asset_cache[cache_key] = (result, _time.time())
    return result